From ff46bcfe20d4a7ea84acc833f5b57b18fe1dc361 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Sun, 20 Nov 2022 06:31:37 +0500 Subject: [PATCH 01/39] 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 02/39] 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], +]); From cb7567a88ae3554d7801600e2e9fe16e26c07fd9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 23 Nov 2022 08:38:20 +0100 Subject: [PATCH 03/39] [4.x] Make `TenantDump` work when called outside the tenant context (#1015) * Run TenantDump as passed tenant * Fix code style (php-cs-fixer) * Fix `tenants:dump` tests * Update dump command test * Remove redundant `tenant-schema.dump` unlinking * Delete duplicate test * Update test name Co-authored-by: PHP CS Fixer --- src/Commands/TenantDump.php | 2 +- tests/CommandsTest.php | 35 +++++++++++++++++++---------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index 3f957bdd..b02af71f 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -41,7 +41,7 @@ class TenantDump extends DumpCommand return 1; } - parent::handle($connections, $dispatcher); + $tenant->run(fn () => parent::handle($connections, $dispatcher)); return 0; } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 95672753..355fb429 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -25,7 +25,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; beforeEach(function () { - if (file_exists($schemaPath = database_path('schema/tenant-schema.dump'))) { + if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { unlink($schemaPath); } @@ -111,28 +111,30 @@ test('migrate command loads schema state', function () { test('dump command works', function () { $tenant = Tenant::create(); + $schemaPath = 'tests/Etc/tenant-schema-test.dump'; + Artisan::call('tenants:migrate'); - tenancy()->initialize($tenant); + expect($schemaPath)->not()->toBeFile(); - Artisan::call('tenants:dump --path="tests/Etc/tenant-schema-test.dump"'); - expect('tests/Etc/tenant-schema-test.dump')->toBeFile(); -}); - -test('tenant dump file gets created as tenant-schema.dump in the database schema folder by default', function() { - config(['tenancy.migration_parameters.--schema-path' => $schemaPath = database_path('schema/tenant-schema.dump')]); - - $tenant = Tenant::create(); - Artisan::call('tenants:migrate'); - - tenancy()->initialize($tenant); - - Artisan::call('tenants:dump'); + Artisan::call('tenants:dump ' . "--tenant='$tenant->id' --path='$schemaPath'"); expect($schemaPath)->toBeFile(); }); -test('migrate command uses the correct schema path by default', function () { +test('dump command generates dump at the passed path', function() { + $tenant = Tenant::create(); + + Artisan::call('tenants:migrate'); + + expect($schemaPath = 'tests/Etc/tenant-schema-test.dump')->not()->toBeFile(); + + Artisan::call("tenants:dump --tenant='$tenant->id' --path='$schemaPath'"); + + expect($schemaPath)->toBeFile(); +}); + +test('migrate command correctly uses the schema dump located at the configured schema path by default', function () { config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']); $tenant = Tenant::create(); @@ -146,6 +148,7 @@ test('migrate command uses the correct schema path by default', function () { tenancy()->initialize($tenant); + // schema_users is a table included in the tests/Etc/tenant-schema dump // Check for both tables to see if missing migrations also get executed expect(Schema::hasTable('schema_users'))->toBeTrue(); expect(Schema::hasTable('users'))->toBeTrue(); From ea19117870a4f0bc091621d23aae70888cb5a799 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 23 Nov 2022 13:12:29 +0100 Subject: [PATCH 04/39] Use the hardcoded default path in `TenantDump` only if the path isn't configured (#1019) * Use the hardcoded tenant dump path only if the path isn't configured * Test generating tenant dump at the configured path --- src/Commands/TenantDump.php | 2 +- tests/CommandsTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index b02af71f..c7bd9b99 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -23,7 +23,7 @@ class TenantDump extends DumpCommand public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int { if (is_null($this->option('path'))) { - $this->input->setOption('path', database_path('schema/tenant-schema.dump')); + $this->input->setOption('path', config('tenancy.migration_parameters.--schema-path') ?? database_path('schema/tenant-schema.dump')); } $tenant = $this->option('tenant') diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 355fb429..d8484253 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -134,6 +134,20 @@ test('dump command generates dump at the passed path', function() { expect($schemaPath)->toBeFile(); }); +test('dump command generates dump at the path specified in the tenancy migration parameters config', function() { + config(['tenancy.migration_parameters.--schema-path' => $schemaPath = 'tests/Etc/tenant-schema-test.dump']); + + $tenant = Tenant::create(); + + Artisan::call('tenants:migrate'); + + expect($schemaPath)->not()->toBeFile(); + + Artisan::call("tenants:dump --tenant='$tenant->id'"); + + expect($schemaPath)->toBeFile(); +}); + test('migrate command correctly uses the schema dump located at the configured schema path by default', function () { config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']); $tenant = Tenant::create(); From 73c5655bc886f46ae4689b29bcd19657ea86a406 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Fri, 25 Nov 2022 07:09:31 +0500 Subject: [PATCH 05/39] Manual mode improvements (use correct event type, add new listeners) (#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix issue and add test * Update CreateTenantConnection.php * add purge call * Update ManualModeTest.php * use tenant connection and central connection listener * Update ManualModeTest.php * fix test * improvements * Update ManualModeTest.php * add comment * simplify comment Co-authored-by: Samuel Štancl --- src/Listeners/CreateTenantConnection.php | 9 ++--- src/Listeners/UseCentralConnection.php | 21 +++++++++++ src/Listeners/UseTenantConnection.php | 21 +++++++++++ tests/ManualModeTest.php | 45 ++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/Listeners/UseCentralConnection.php create mode 100644 src/Listeners/UseTenantConnection.php create mode 100644 tests/ManualModeTest.php diff --git a/src/Listeners/CreateTenantConnection.php b/src/Listeners/CreateTenantConnection.php index b4983d32..6af18a10 100644 --- a/src/Listeners/CreateTenantConnection.php +++ b/src/Listeners/CreateTenantConnection.php @@ -6,7 +6,7 @@ namespace Stancl\Tenancy\Listeners; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; -use Stancl\Tenancy\Events\Contracts\TenantEvent; +use Stancl\Tenancy\Events\Contracts\TenancyEvent; class CreateTenantConnection { @@ -15,11 +15,12 @@ class CreateTenantConnection ) { } - public function handle(TenantEvent $event): void + public function handle(TenancyEvent $event): void { - /** @var TenantWithDatabase */ - $tenant = $event->tenant; + /** @var TenantWithDatabase $tenant */ + $tenant = $event->tenancy->tenant; + $this->database->purgeTenantConnection(); $this->database->createTenantConnection($tenant); } } diff --git a/src/Listeners/UseCentralConnection.php b/src/Listeners/UseCentralConnection.php new file mode 100644 index 00000000..716a5148 --- /dev/null +++ b/src/Listeners/UseCentralConnection.php @@ -0,0 +1,21 @@ +database->reconnectToCentral(); + } +} diff --git a/src/Listeners/UseTenantConnection.php b/src/Listeners/UseTenantConnection.php new file mode 100644 index 00000000..a4c12108 --- /dev/null +++ b/src/Listeners/UseTenantConnection.php @@ -0,0 +1,21 @@ +database->setDefaultConnection('tenant'); + } +} diff --git a/tests/ManualModeTest.php b/tests/ManualModeTest.php new file mode 100644 index 00000000..fe1ba9a6 --- /dev/null +++ b/tests/ManualModeTest.php @@ -0,0 +1,45 @@ +send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + Event::listen(TenancyInitialized::class, CreateTenantConnection::class); + Event::listen(TenancyInitialized::class, UseTenantConnection::class); + Event::listen(TenancyEnded::class, UseCentralConnection::class); + + $tenant = Tenant::create(); + + expect(app('db')->getDefaultConnection())->toBe('central'); + expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']); + pest()->assertArrayNotHasKey('tenant', config('database.connections')); + + tenancy()->initialize($tenant); + + // Trigger creation of the tenant connection + createUsersTable(); + + expect(app('db')->getDefaultConnection())->toBe('tenant'); + expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant']); + pest()->assertArrayHasKey('tenant', config('database.connections')); + + tenancy()->end(); + + expect(array_keys(app('db')->getConnections()))->toBe(['central']); + expect(config('database.connections.tenant'))->toBeNull(); + expect(app('db')->getDefaultConnection())->toBe(config('tenancy.database.central_connection')); +}); From 7d3298c6bb40d3977770675b008ae3f138364f26 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Nov 2022 09:31:07 +0100 Subject: [PATCH 06/39] Improve code of pending tenants (#1025) * Remove `--all` option from ClearPendingTenants * Improve query formatting * Remove redundant test * Convert time constrait options to int * Improve CreatePendingTenants success message --- src/Commands/ClearPendingTenants.php | 38 +++++++++++---------------- src/Commands/CreatePendingTenants.php | 7 +++-- src/Concerns/HasTenantOptions.php | 3 +-- tests/PendingTenantsTest.php | 17 ------------ 4 files changed, 19 insertions(+), 46 deletions(-) diff --git a/src/Commands/ClearPendingTenants.php b/src/Commands/ClearPendingTenants.php index 19d31195..82b01cd0 100644 --- a/src/Commands/ClearPendingTenants.php +++ b/src/Commands/ClearPendingTenants.php @@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Builder; class ClearPendingTenants extends Command { protected $signature = 'tenants:pending-clear - {--all : Override the default settings and deletes all pending tenants} {--older-than-days= : Deletes all pending tenants older than the amount of days} {--older-than-hours= : Deletes all pending tenants older than the amount of hours}'; @@ -24,32 +23,25 @@ class ClearPendingTenants extends Command // We compare the original expiration date to the new one to check if the new one is different later $originalExpirationDate = $expirationDate->copy()->toImmutable(); - // Skip the time constraints if the 'all' option is given - if (! $this->option('all')) { - /** @var ?int $olderThanDays */ - $olderThanDays = $this->option('older-than-days'); + $olderThanDays = (int) $this->option('older-than-days'); + $olderThanHours = (int) $this->option('older-than-hours'); - /** @var ?int $olderThanHours */ - $olderThanHours = $this->option('older-than-hours'); + if ($olderThanDays && $olderThanHours) { + $this->line(" Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components + $this->line('Please, choose only one of these options.'); - if ($olderThanDays && $olderThanHours) { - $this->line(" Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components - $this->line('Please, choose only one of these options.'); - - return 1; // Exit code for failure - } - - if ($olderThanDays) { - $expirationDate->subDays($olderThanDays); - } - - if ($olderThanHours) { - $expirationDate->subHours($olderThanHours); - } + return 1; // Exit code for failure } - $deletedTenantCount = tenancy() - ->query() + if ($olderThanDays) { + $expirationDate->subDays($olderThanDays); + } + + if ($olderThanHours) { + $expirationDate->subHours($olderThanHours); + } + + $deletedTenantCount = tenancy()->query() ->onlyPending() ->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) { $query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp); diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php index 7b2c7934..5c255664 100644 --- a/src/Commands/CreatePendingTenants.php +++ b/src/Commands/CreatePendingTenants.php @@ -30,8 +30,8 @@ class CreatePendingTenants extends Command $createdCount++; } - $this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.'); - $this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); + $this->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.'); + $this->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); return 0; } @@ -39,8 +39,7 @@ class CreatePendingTenants extends Command /** Calculate the number of currently available pending tenants. */ protected function getPendingTenantCount(): int { - return tenancy() - ->query() + return tenancy()->query() ->onlyPending() ->count(); } diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index f8a763a7..b558da64 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -23,8 +23,7 @@ trait HasTenantOptions protected function getTenants(): LazyCollection { - return tenancy() - ->query() + return tenancy()->query() ->when($this->option('tenants'), function ($query) { $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); }) diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 8dbda9ee..26fd5c34 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -67,23 +67,6 @@ test('CreatePendingTenants command cannot run with both time constraints', funct ->assertFailed(); }); -test('CreatePendingTenants commands all option overrides any config constraints', function () { - Tenant::createPending(); - Tenant::createPending(); - - tenancy()->model()->query()->onlyPending()->first()->update([ - 'pending_since' => now()->subDays(10) - ]); - - config(['tenancy.pending.older_than_days' => 4]); - - Artisan::call(ClearPendingTenants::class, [ - '--all' => true - ]); - - expect(Tenant::onlyPending()->count())->toBe(0); -}); - test('tenancy can check if there are any pending tenants', function () { expect(Tenant::onlyPending()->exists())->toBeFalse(); From ad29909a16aaefefeae74ddbe066f42d49ba21b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 29 Nov 2022 10:25:22 +0100 Subject: [PATCH 07/39] Make $tenantId nullable in initializeTenancyForQueue (revert phpstan change) --- src/Bootstrappers/QueueTenancyBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 5b6ef4d8..92c95ef6 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -79,9 +79,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails } - protected static function initializeTenancyForQueue(string|int $tenantId): void + protected static function initializeTenancyForQueue(string|int|null $tenantId): void { - if (! $tenantId) { + if ($tenantId === null) { // The job is not tenant-aware if (tenancy()->initialized) { // Tenancy was initialized, so we revert back to the central context From 45aac1a718282a872856e66ac31620c01da103c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 29 Nov 2022 10:31:37 +0100 Subject: [PATCH 08/39] phpstan fixes --- src/Middleware/InitializeTenancyByPath.php | 2 +- src/Middleware/PreventAccessFromUnwantedDomains.php | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index 4a9f25bc..fc27cae0 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -48,7 +48,7 @@ class InitializeTenancyByPath extends IdentificationMiddleware protected function route(Request $request): Route { - /** @var Route $route */ + /** @var ?Route $route */ $route = $request->route(); if (! $route) { diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index 1c01cc9e..977d2021 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -20,7 +20,10 @@ class PreventAccessFromUnwantedDomains /** @return \Illuminate\Http\Response|mixed */ public function handle(Request $request, Closure $next): mixed { - if ($this->routeHasMiddleware($request->route(), 'universal')) { + /** @var Route $route */ + $route = $request->route(); + + if ($this->routeHasMiddleware($route, 'universal')) { return $next($request); } From a7ad8287e6b8359230bae254ebe01e66c87a8897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 2 Dec 2022 19:43:20 +0100 Subject: [PATCH 09/39] disable new jobs/listeners by default, add CreateTenantStorage job --- assets/TenancyServiceProvider.stub.php | 8 +++++--- src/Listeners/CreateTenantStorage.php | 18 ++++++++++++++++++ src/Listeners/DeleteTenantStorage.php | 3 +++ 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 src/Listeners/CreateTenantStorage.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 3092c428..6735b37f 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -28,14 +28,16 @@ class TenancyServiceProvider extends ServiceProvider Jobs\CreateDatabase::class, Jobs\MigrateDatabase::class, // Jobs\SeedDatabase::class, - Jobs\CreateStorageSymlinks::class, + + // Jobs\CreateStorageSymlinks::class, // Your own jobs to prepare the tenant. // Provision API keys, create S3 buckets, anything you want! - ])->send(function (Events\TenantCreated $event) { return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + + // Listeners\CreateTenantStorage::class, ], Events\SavingTenant::class => [], Events\TenantSaved::class => [], @@ -53,7 +55,7 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, - Jobs\RemoveStorageSymlinks::class, + // Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. diff --git a/src/Listeners/CreateTenantStorage.php b/src/Listeners/CreateTenantStorage.php new file mode 100644 index 00000000..51fa9d23 --- /dev/null +++ b/src/Listeners/CreateTenantStorage.php @@ -0,0 +1,18 @@ +tenant->run(fn () => storage_path()); + + mkdir("$storage_path", 0777, true); // Create the tenant's folder inside storage/ + mkdir("$storage_path/framework/cache", 0777, true); // Create /framework/cache inside the tenant's storage (used for e.g. real-time facades) + } +} diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php index ce1a4203..9cc1daae 100644 --- a/src/Listeners/DeleteTenantStorage.php +++ b/src/Listeners/DeleteTenantStorage.php @@ -11,6 +11,9 @@ class DeleteTenantStorage { public function handle(DeletingTenant $event): void { + // todo@lukas since this is using the 'File' facade instead of low-level PHP functions, Tenancy might affect this? + // Therefore, when Tenancy is initialized, this might look INSIDE the tenant's storage, instead of the main storage dir? + // The DeletingTenant event will be fired in the central context in 99% of cases, but sometimes it might run in the tenant context (from another tenant) so we want to make sure this works well in all contexts. File::deleteDirectory($event->tenant->run(fn () => storage_path())); } } From 68de3600bd6418eae3ea23d460c527e82d45608a Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Wed, 14 Dec 2022 19:08:00 +0500 Subject: [PATCH 10/39] Improve commands CLI output (#1030) * use component info/error methods * Update src/Commands/ClearPendingTenants.php Co-authored-by: lukinovec Co-authored-by: lukinovec --- src/Commands/ClearPendingTenants.php | 7 +++---- src/Commands/CreatePendingTenants.php | 6 +++--- src/Commands/Link.php | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Commands/ClearPendingTenants.php b/src/Commands/ClearPendingTenants.php index 82b01cd0..0e27a209 100644 --- a/src/Commands/ClearPendingTenants.php +++ b/src/Commands/ClearPendingTenants.php @@ -17,7 +17,7 @@ class ClearPendingTenants extends Command public function handle(): int { - $this->info('Removing pending tenants.'); + $this->components->info('Removing pending tenants.'); $expirationDate = now(); // We compare the original expiration date to the new one to check if the new one is different later @@ -27,8 +27,7 @@ class ClearPendingTenants extends Command $olderThanHours = (int) $this->option('older-than-hours'); if ($olderThanDays && $olderThanHours) { - $this->line(" Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components - $this->line('Please, choose only one of these options.'); + $this->components->error("Cannot use '--older-than-days' and '--older-than-hours' together. Please, choose only one of these options."); return 1; // Exit code for failure } @@ -51,7 +50,7 @@ class ClearPendingTenants extends Command ->delete() ->count(); - $this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.'); + $this->components->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.'); return 0; } diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php index 5c255664..c37b8bd7 100644 --- a/src/Commands/CreatePendingTenants.php +++ b/src/Commands/CreatePendingTenants.php @@ -14,7 +14,7 @@ class CreatePendingTenants extends Command public function handle(): int { - $this->info('Creating pending tenants.'); + $this->components->info('Creating pending tenants.'); $maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count')); $pendingTenantCount = $this->getPendingTenantCount(); @@ -30,8 +30,8 @@ class CreatePendingTenants extends Command $createdCount++; } - $this->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.'); - $this->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); + $this->components->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.'); + $this->components->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); return 0; } diff --git a/src/Commands/Link.php b/src/Commands/Link.php index a6dd6c5f..d49cc7f2 100644 --- a/src/Commands/Link.php +++ b/src/Commands/Link.php @@ -34,7 +34,7 @@ class Link extends Command $this->createLinks($tenants); } } catch (Exception $exception) { - $this->error($exception->getMessage()); + $this->components->error($exception->getMessage()); return 1; } From 21dc69e35830f66571a5f195d283098f3500a882 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 15 Dec 2022 15:03:03 +0100 Subject: [PATCH 11/39] Use invade in BootstrapperTest (#1033) --- tests/BootstrapperTest.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index ba4ea41a..da51cbde 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -331,15 +331,7 @@ function getDiskPrefix(string $disk): string /** @var FilesystemAdapter $disk */ $disk = Storage::disk($disk); $adapter = $disk->getAdapter(); + $prefix = invade(invade($adapter)->prefixer)->prefix; - $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); - $prefixer->setAccessible(true); - - // reflection -> instance - $prefixer = $prefixer->getValue($adapter); - - $prefix = (new ReflectionProperty($prefixer, 'prefix')); - $prefix->setAccessible(true); - - return $prefix->getValue($prefixer); + return $prefix; } From f42f08cb874001b8626d193022cfb994069d013e Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Sat, 17 Dec 2022 06:08:03 +0500 Subject: [PATCH 12/39] Add session state when impersonating tenant (#1029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Fix code style (php-cs-fixer) * Update TenantUserImpersonationTest.php * renamed method * update method name in test * rename session key * fix test * Update src/Features/UserImpersonation.php Co-authored-by: Samuel Štancl * Update UserImpersonation.php Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- src/Features/UserImpersonation.php | 17 +++++++++++++++++ tests/TenantUserImpersonationTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 4c9bb104..608bed07 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -48,6 +48,23 @@ class UserImpersonation implements Feature $token->delete(); + session()->put('tenancy_impersonating', true); + return redirect($token->redirect_url); } + + public static function isImpersonating(): bool + { + return session()->has('tenancy_impersonating'); + } + + /** + * Logout from the current domain and forget impersonation session. + */ + public static function leave(): void // todo possibly rename + { + auth()->logout(); + + session()->forget('tenancy_impersonating'); + } } diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 0fcb9022..1e72c604 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -83,6 +83,19 @@ test('tenant user can be impersonated on a tenant domain', function () { pest()->get('http://foo.localhost/dashboard') ->assertSuccessful() ->assertSee('You are logged in as Joe'); + + expect(UserImpersonation::isImpersonating())->toBeTrue(); + expect(session('tenancy_impersonating'))->toBeTrue(); + + // Leave impersonation + UserImpersonation::leave(); + + expect(UserImpersonation::isImpersonating())->toBeFalse(); + expect(session('tenancy_impersonating'))->toBeNull(); + + // Assert can't access the tenant dashboard + pest()->get('http://foo.localhost/dashboard') + ->assertRedirect('http://foo.localhost/login'); }); test('tenant user can be impersonated on a tenant path', function () { @@ -116,6 +129,19 @@ test('tenant user can be impersonated on a tenant path', function () { pest()->get('/acme/dashboard') ->assertSuccessful() ->assertSee('You are logged in as Joe'); + + expect(UserImpersonation::isImpersonating())->toBeTrue(); + expect(session('tenancy_impersonating'))->toBeTrue(); + + // Leave impersonation + UserImpersonation::leave(); + + expect(UserImpersonation::isImpersonating())->toBeFalse(); + expect(session('tenancy_impersonating'))->toBeNull(); + + // Assert can't access the tenant dashboard + pest()->get('/acme/dashboard') + ->assertRedirect('/login'); }); test('tokens have a limited ttl', function () { From 82fa6cb292ebc3438c9cf80cfdfa058136809c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 20 Dec 2022 15:10:29 +0100 Subject: [PATCH 13/39] fix tenant() relationship in Domain --- src/Database/Models/Domain.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Models/Domain.php b/src/Database/Models/Domain.php index e5c49bcf..4d552b12 100644 --- a/src/Database/Models/Domain.php +++ b/src/Database/Models/Domain.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Contracts; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Events; +use Stancl\Tenancy\Tenancy; /** * @property string $domain @@ -28,7 +29,7 @@ class Domain extends Model implements Contracts\Domain public function tenant(): BelongsTo { - return $this->belongsTo(config('tenancy.models.tenant')); + return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn()); } protected $dispatchesEvents = [ From 0f892f1585cd79dc48f9e4e50c14c5131c1f5995 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 4 Jan 2023 02:12:25 +0100 Subject: [PATCH 14/39] Make tenants able to have custom mail credentials (#989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace MailManager singleton with an instance of a custom mail manager which always resolves the mailers instead of getting the cached ones * Fix code style (php-cs-fixer) * Add MailTenancyBootstrapper * Add MailTenancyBootstrapper to tenancy.bootstrappers config (commented out) * Fix code style (php-cs-fixer) * Make credentials map a public static property * Always resolve only the mailers specified in the mailersToNotCache public static property * Fix typo in comment * Update TenancyServiceProvider comment * add todo * Add comments to TenancyMailManager, rename property * Remove the configKey array check * Simplify bootstrap method * Change $credentialsMap so that config keys are the keys, and the tenant property names are the values * Rename $mailersToAlwaysResolve to $tenantMailers * Update comment * Update comment * Rename variable in TenancyServiceProvider comment * Scaffold tests * Update comments after review * Uncomment MailTenancyBootstrapper in config * Use array_key_exists instead of null check * Split config logic into methods * Update mapping credentials * Add tests for the added logic * Fix code style (php-cs-fixer) * Delete default 'smtp' mailer in $tenantMailers * Add separate method to pick the appropriate mail credentials map preset * Specify test name * Move mail bootstrapper tests to BootstrapperTest * Depend less on the default mailer by adding a static `$mailer` property * Use static property for map presets * Comment out MailTenancyBootstrapper from config * Add return types to MailTenancyBootstrapper methods * Update test name * Move MailManager extension to MailTenancyBootstrapper * Fix code style (php-cs-fixer) * Update config reverting test * Use `invade()` instead of ReflectionClass * Fix constructor parameter formatting * Delete TenancyMailManager, update tests * Add return type * Update comment * Update MailTest * Delete `group('mailer')` * Delete bindNewMailManagerInstance() * Delete remaining `group('mailer')` * Fix comment * Fix comment Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- assets/config.php | 1 + src/Bootstrappers/MailTenancyBootstrapper.php | 79 +++++++++++++++++++ tests/BootstrapperTest.php | 45 +++++++++++ tests/MailTest.php | 72 +++++++++++++++++ tests/TestCase.php | 3 + 5 files changed, 200 insertions(+) create mode 100644 src/Bootstrappers/MailTenancyBootstrapper.php create mode 100644 tests/MailTest.php diff --git a/assets/config.php b/assets/config.php index 3778e107..fab224db 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\MailTenancyBootstrapper::class, // Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], diff --git a/src/Bootstrappers/MailTenancyBootstrapper.php b/src/Bootstrappers/MailTenancyBootstrapper.php new file mode 100644 index 00000000..7f15f547 --- /dev/null +++ b/src/Bootstrappers/MailTenancyBootstrapper.php @@ -0,0 +1,79 @@ + 'tenant_property', + * ] + */ + public static array $credentialsMap = []; + + public static string|null $mailer = null; + + protected array $originalConfig = []; + + public static array $mapPresets = [ + 'smtp' => [ + 'mail.mailers.smtp.host' => 'smtp_host', + 'mail.mailers.smtp.port' => 'smtp_port', + 'mail.mailers.smtp.username' => 'smtp_username', + 'mail.mailers.smtp.password' => 'smtp_password', + ], + ]; + + public function __construct( + protected Repository $config, + protected Application $app + ) { + static::$mailer ??= $config->get('mail.default'); + static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$mailer] ?? []); + } + + public function bootstrap(Tenant $tenant): void + { + // Forget the mail manager instance to clear the cached mailers + $this->app->forgetInstance('mail.manager'); + + $this->setConfig($tenant); + } + + public function revert(): void + { + $this->unsetConfig(); + + $this->app->forgetInstance('mail.manager'); + } + + protected function setConfig(Tenant $tenant): void + { + foreach (static::$credentialsMap as $configKey => $storageKey) { + $override = $tenant->$storageKey; + + if (array_key_exists($storageKey, $tenant->getAttributes())) { + $this->originalConfig[$configKey] ??= $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + + protected function unsetConfig(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index da51cbde..3cc50b58 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Str; +use Illuminate\Mail\MailManager; use Illuminate\Support\Facades\DB; use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\File; @@ -23,6 +24,7 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; @@ -326,6 +328,49 @@ test('local storage public urls are generated correctly', function() { expect(File::isDirectory($tenantStoragePath))->toBeFalse(); }); +test('MailTenancyBootstrapper maps tenant mail credentials to config as specified in the $credentialsMap property and makes the mailer use tenant credentials', function() { + MailTenancyBootstrapper::$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' + ]); + + $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() { + MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; + config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'no password']); + + 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); +}); + function getDiskPrefix(string $disk): string { /** @var FilesystemAdapter $disk */ diff --git a/tests/MailTest.php b/tests/MailTest.php new file mode 100644 index 00000000..544fda1b --- /dev/null +++ b/tests/MailTest.php @@ -0,0 +1,72 @@ + 'smtp']); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +// Initialize tenancy as $tenant and assert that the smtp mailer's transport has the correct password +function assertMailerTransportUsesPassword(string|null $password) { + $manager = app(MailManager::class); + $mailer = invade($manager)->get('smtp'); + $mailerPassword = invade($mailer->getSymfonyTransport())->password; + + expect($mailerPassword)->toBe((string) $password); +}; + +test('mailer transport uses the correct credentials', function() { + config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']); + MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; + + tenancy()->initialize($tenant = Tenant::create()); + assertMailerTransportUsesPassword($defaultPassword); // $tenant->smtp_password is not set, so the default password should be used + tenancy()->end(); + + // Assert mailer uses the updated password + $tenant->update(['smtp_password' => $newPassword = 'changed']); + + tenancy()->initialize($tenant); + assertMailerTransportUsesPassword($newPassword); + tenancy()->end(); + + // Assert mailer uses the correct password after switching to a different tenant + tenancy()->initialize(Tenant::create(['smtp_password' => $newTenantPassword = 'updated'])); + assertMailerTransportUsesPassword($newTenantPassword); + tenancy()->end(); + + // Assert mailer uses the default password after tenancy ends + assertMailerTransportUsesPassword($defaultPassword); +}); + + +test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() { + $mailers = fn() => invade(app(MailManager::class))->mailers; + + app(MailManager::class)->mailer('smtp'); + + expect($mailers())->toHaveCount(1); + + tenancy()->initialize(Tenant::create()); + + expect($mailers())->toHaveCount(0); + + app(MailManager::class)->mailer('smtp'); + + expect($mailers())->toHaveCount(1); + + tenancy()->end(); + + expect($mailers())->toHaveCount(0); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7b9deea0..07af199f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,6 +14,7 @@ use Stancl\Tenancy\Facades\GlobalCache; use Stancl\Tenancy\Facades\Tenancy; use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -104,6 +105,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--force' => true, ], 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class, 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, @@ -113,6 +115,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase ]); $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration + $app->singleton(MailTenancyBootstrapper::class); } protected function getPackageProviders($app) From 03ac1ef12768c03d119aa6296413709a80062dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 4 Jan 2023 02:23:48 +0100 Subject: [PATCH 15/39] fix phpstan errors (seems like it started ignoring @property annotations on interfaces and abstract classes) --- phpstan.neon | 4 +++- src/Contracts/Domain.php | 2 +- src/Resolvers/DomainTenantResolver.php | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index a6bce96d..7ae06b44 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -28,7 +28,7 @@ parameters: paths: - src/Features/TelescopeTags.php - - message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#' + message: '#Parameter \#1 \$key of method Illuminate\\Cache\\Repository::put\(\) expects#' paths: - src/helpers.php - @@ -48,6 +48,8 @@ parameters: paths: - src/Database/DatabaseConfig.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' + - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' + - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false diff --git a/src/Contracts/Domain.php b/src/Contracts/Domain.php index a9a19a50..cfe89f43 100644 --- a/src/Contracts/Domain.php +++ b/src/Contracts/Domain.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * * @see \Stancl\Tenancy\Database\Models\Domain * - * @method __call(string $method, array $parameters) IDE support. This will be a model. + * @method __call(string $method, array $parameters) IDE support. This will be a model. // todo check if we can remove these now * @method static __callStatic(string $method, array $parameters) IDE support. This will be a model. * @mixin \Illuminate\Database\Eloquent\Model */ diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 2163febe..ba4e66a0 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Resolvers; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; @@ -39,14 +40,17 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver protected function setCurrentDomain(Tenant $tenant, string $domain): void { + /** @var Tenant&Model $tenant */ static::$currentDomain = $tenant->domains->where('domain', $domain)->first(); } public function getArgsForTenant(Tenant $tenant): array { + /** @var Tenant&Model $tenant */ + $tenant->unsetRelation('domains'); - return $tenant->domains->map(function (Domain $domain) { + return $tenant->domains->map(function (Domain&Model $domain) { return [$domain->domain]; })->toArray(); } From d4c6c34e7c3e320fafd7eb6630e0b71fac4de83b Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Wed, 4 Jan 2023 01:24:21 +0000 Subject: [PATCH 16/39] Fix code style (php-cs-fixer) --- src/Resolvers/DomainTenantResolver.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index ba4e66a0..ceecd0b6 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -47,7 +47,6 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver public function getArgsForTenant(Tenant $tenant): array { /** @var Tenant&Model $tenant */ - $tenant->unsetRelation('domains'); return $tenant->domains->map(function (Domain&Model $domain) { From d0dd87ab07868f7cccc4a6412eb466319156a7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 4 Jan 2023 02:43:10 +0100 Subject: [PATCH 17/39] bump PHP to 8.2, minor ci fixes --- Dockerfile | 2 +- composer.json | 12 ++++++------ docker-compose.yml | 2 +- tests/CommandsTest.php | 2 +- tests/Etc/Console/ExampleCommand.php | 12 ++++++------ 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0ced8009..5dfe442c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # add amd64 platform to support Mac M1 FROM --platform=linux/amd64 shivammathur/node:latest-amd64 -ARG PHP_VERSION=8.1 +ARG PHP_VERSION=8.2 WORKDIR /var/www/html diff --git a/composer.json b/composer.json index 68f16f25..0cfe3984 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*", "illuminate/support": "^9.0", "spatie/ignition": "^1.4", @@ -58,16 +58,16 @@ } }, "scripts": { - "docker-up": "PHP_VERSION=8.1 docker-compose up -d", - "docker-down": "PHP_VERSION=8.1 docker-compose down", - "docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build", + "docker-up": "PHP_VERSION=8.2 docker-compose up -d", + "docker-down": "PHP_VERSION=8.2 docker-compose down", + "docker-rebuild": "PHP_VERSION=8.2 docker-compose up -d --no-deps --build", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", "phpstan-pro": "vendor/bin/phpstan --pro", "cs": "php-cs-fixer fix --config=.php-cs-fixer.php", - "test": "PHP_VERSION=8.1 ./test --no-coverage", - "test-full": "PHP_VERSION=8.1 ./test" + "test": "PHP_VERSION=8.2 ./test --no-coverage", + "test-full": "PHP_VERSION=8.2 ./test" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docker-compose.yml b/docker-compose.yml index 116b48f1..465b36cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: build: context: . args: - PHP_VERSION: ${PHP_VERSION:-8.1} + PHP_VERSION: ${PHP_VERSION:-8.2} depends_on: mysql: condition: service_healthy diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index d8484253..7d6f0884 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -372,7 +372,7 @@ function runCommandWorks(): void Artisan::call('tenants:migrate', ['--tenants' => [$id]]); pest()->artisan("tenants:run --tenants=$id 'foo foo --b=bar --c=xyz' ") - ->expectsOutput("User's name is Test command") + ->expectsOutput("User's name is Test user") ->expectsOutput('foo') ->expectsOutput('xyz'); } diff --git a/tests/Etc/Console/ExampleCommand.php b/tests/Etc/Console/ExampleCommand.php index 72263b37..cdd7b551 100644 --- a/tests/Etc/Console/ExampleCommand.php +++ b/tests/Etc/Console/ExampleCommand.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc\Console; +use Illuminate\Support\Str; use Illuminate\Console\Command; class ExampleCommand extends Command @@ -22,14 +23,13 @@ class ExampleCommand extends Command */ public function handle() { - User::create([ - 'id' => 999, - 'name' => 'Test command', - 'email' => 'test@command.com', + $id = User::create([ + 'name' => 'Test user', + 'email' => Str::random(8) . '@example.com', 'password' => bcrypt('password'), - ]); + ])->id; - $this->line("User's name is " . User::find(999)->name); + $this->line("User's name is " . User::find($id)->name); $this->line($this->argument('a')); $this->line($this->option('c')); } From 9078280a44c3c5d5d4e37ad3c7fc356461603e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 4 Jan 2023 03:04:00 +0100 Subject: [PATCH 18/39] revert to 8.1 in CI for now --- Dockerfile | 3 ++- INTERNAL.md | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 INTERNAL.md diff --git a/Dockerfile b/Dockerfile index 5dfe442c..73a052d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # add amd64 platform to support Mac M1 FROM --platform=linux/amd64 shivammathur/node:latest-amd64 -ARG PHP_VERSION=8.2 +# todo update this to 8.2 once shivammathur/node supports that +ARG PHP_VERSION=8.1 WORKDIR /var/www/html diff --git a/INTERNAL.md b/INTERNAL.md new file mode 100644 index 00000000..4b3297dd --- /dev/null +++ b/INTERNAL.md @@ -0,0 +1,8 @@ +# Internal development notes + +## Updating the docker image used by the GH action + +1. Login in to Docker Hub: `docker login -u archtechx -p` +2. Build the image (probably shut down docker-compose containers first): `docker-compose build --no-cache` +3. Tag a new image: `docker tag tenancy_test archtechx/tenancy:latest` +4. Push the image: `docker push archtechx/tenancy:latest` From db1dc334a63058eb3ad976bdc1bc90606d958175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 4 Jan 2023 03:04:56 +0100 Subject: [PATCH 19/39] add todo --- src/Tenancy.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tenancy.php b/src/Tenancy.php index e8187dd8..991f9234 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -118,6 +118,7 @@ class Tenancy */ public static function find(int|string $id): Tenant|null { + // todo update all syntax like this once we're fully on PHP 8.2 /** @var (Tenant&Model)|null */ $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); From 32a128d65750b178e3ed32c70d42ab57dca860c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 4 Jan 2023 03:08:56 +0100 Subject: [PATCH 20/39] lower required php version back to 8.1 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0cfe3984..098b1cc4 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "^8.2", + "php": "^8.1", "ext-json": "*", "illuminate/support": "^9.0", "spatie/ignition": "^1.4", From 24d71230e8231d6086f5eaf316ae34129a1e02d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 4 Jan 2023 03:18:52 +0100 Subject: [PATCH 21/39] comment out php 8.2 phpstan ignores --- phpstan.neon | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 7ae06b44..6a864833 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -48,8 +48,10 @@ parameters: paths: - src/Database/DatabaseConfig.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' + + # php 8.2 + # - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' + # - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false From 48fc63fe02363af3ac3baa5c8fcf1a21d84a5336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 6 Jan 2023 02:38:31 +0100 Subject: [PATCH 22/39] switch to PHP 8.2 --- Dockerfile | 3 +-- composer.json | 2 +- phpstan.neon | 6 ++---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 73a052d5..5dfe442c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,7 @@ # add amd64 platform to support Mac M1 FROM --platform=linux/amd64 shivammathur/node:latest-amd64 -# todo update this to 8.2 once shivammathur/node supports that -ARG PHP_VERSION=8.1 +ARG PHP_VERSION=8.2 WORKDIR /var/www/html diff --git a/composer.json b/composer.json index 098b1cc4..0cfe3984 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*", "illuminate/support": "^9.0", "spatie/ignition": "^1.4", diff --git a/phpstan.neon b/phpstan.neon index 6a864833..7ae06b44 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -48,10 +48,8 @@ parameters: paths: - src/Database/DatabaseConfig.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - - # php 8.2 - # - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - # - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' + - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' + - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false From 21d55ef4726cca70179842c3d26266602f8bbdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 6 Jan 2023 02:44:37 +0100 Subject: [PATCH 23/39] add setup-php to phpstan job --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 724aed35..dc61273d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,11 @@ jobs: name: Static analysis (PHPStan) runs-on: ubuntu-latest steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: imagick, swoole - uses: actions/checkout@v2 - name: Install composer dependencies run: composer install From 3e13e62806765fe04a8299b2ed766bfef4905c8f Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 18 Jan 2023 19:46:47 +0200 Subject: [PATCH 24/39] CachedTenantResolver missing cache case crash fix. (#1005) * CachedTenantResolver missing cache case crash fix. * Check if cached tenant is truthy Co-authored-by: rty Co-authored-by: lukinovec --- src/Resolvers/Contracts/CachedTenantResolver.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index b6a4b15c..13a1f6b1 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -27,9 +27,7 @@ abstract class CachedTenantResolver implements TenantResolver $key = $this->getCacheKey(...$args); - if ($this->cache->has($key)) { - $tenant = $this->cache->get($key); - + if ($tenant = $this->cache->get($key)) { $this->resolved($tenant, ...$args); return $tenant; From c74a7b6fd424ce94b5dacbbe162e0ba60ade64d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 31 Jan 2023 03:59:33 +0100 Subject: [PATCH 25/39] remove debuggable trait, update larastan --- composer.json | 2 +- src/Concerns/Debuggable.php | 72 ----------------------- src/Database/Concerns/BelongsToTenant.php | 3 +- src/Enums/LogMode.php | 12 ---- src/Tenancy.php | 15 ++--- src/TenancyServiceProvider.php | 12 ---- tests/DebuggableTest.php | 68 --------------------- 7 files changed, 7 insertions(+), 177 deletions(-) delete mode 100644 src/Concerns/Debuggable.php delete mode 100644 src/Enums/LogMode.php delete mode 100644 tests/DebuggableTest.php diff --git a/composer.json b/composer.json index 0cdb3c66..0ca231c4 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "doctrine/dbal": "^2.10", "spatie/valuestore": "^1.2.5", "pestphp/pest": "^1.21", - "nunomaduro/larastan": "^1.0", + "nunomaduro/larastan": "^2.4", "spatie/invade": "^1.1" }, "autoload": { diff --git a/src/Concerns/Debuggable.php b/src/Concerns/Debuggable.php deleted file mode 100644 index ff781f89..00000000 --- a/src/Concerns/Debuggable.php +++ /dev/null @@ -1,72 +0,0 @@ -eventLog = []; - $this->logMode = $mode; - - return $this; - } - - public function logMode(): LogMode - { - return $this->logMode; - } - - public function getLog(): array - { - return $this->eventLog; - } - - public function logEvent(TenancyEvent $event): static - { - $this->eventLog[] = ['time' => now(), 'event' => $event::class, 'tenant' => $this->tenant]; - - return $this; - } - - public function dump(Closure $dump = null): static - { - $dump ??= dd(...); - - // Dump the log if we were already logging in silent mode - // Otherwise start logging in instant mode - match ($this->logMode) { - LogMode::NONE => $this->log(LogMode::INSTANT), - LogMode::SILENT => $dump($this->eventLog), - LogMode::INSTANT => null, - }; - - return $this; - } - - public function dd(Closure $dump = null): void - { - $dump ??= dd(...); - - if ($this->logMode === LogMode::SILENT) { - $dump($this->eventLog); - } else { - $dump($this); - } - } -} diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index ccf87c81..3ca9703c 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\TenantScope; use Stancl\Tenancy\Tenancy; @@ -13,7 +14,7 @@ use Stancl\Tenancy\Tenancy; */ trait BelongsToTenant { - public function tenant() + public function tenant(): BelongsTo { return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn()); } diff --git a/src/Enums/LogMode.php b/src/Enums/LogMode.php deleted file mode 100644 index 77d6f073..00000000 --- a/src/Enums/LogMode.php +++ /dev/null @@ -1,12 +0,0 @@ - $class */ $class = config('tenancy.models.tenant'); - /** @var Tenant&Model $model */ $model = new $class; return $model; @@ -113,13 +110,9 @@ class Tenancy /** * Try to find a tenant using an ID. - * - * @return (Tenant&Model)|null */ - public static function find(int|string $id): Tenant|null + public static function find(int|string $id): (Tenant&Model)|null { - // todo update all syntax like this once we're fully on PHP 8.2 - /** @var (Tenant&Model)|null */ $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); return $tenant; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index ded96f35..9d2d56c4 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -121,18 +121,6 @@ class TenancyServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/../assets/routes.php'); } - Event::listen('Stancl\\Tenancy\\Events\\*', function (string $name, array $data) { - $event = $data[0]; - - if ($event instanceof TenancyEvent) { - match (tenancy()->logMode()) { - LogMode::SILENT => tenancy()->logEvent($event), - LogMode::INSTANT => dump($event), // todo1 perhaps still log - default => null, - }; - } - }); - $this->app->singleton('globalUrl', function ($app) { if ($app->bound(FilesystemTenancyBootstrapper::class)) { $instance = clone $app['url']; diff --git a/tests/DebuggableTest.php b/tests/DebuggableTest.php deleted file mode 100644 index 49e180d7..00000000 --- a/tests/DebuggableTest.php +++ /dev/null @@ -1,68 +0,0 @@ -log(LogMode::SILENT); - - $tenant = Tenant::create(); - - tenancy()->initialize($tenant); - - tenancy()->end(); - - assertTenancyInitializedAndEnded(tenancy()->getLog(), $tenant); -}); - -test('tenancy logs event silently by default', function () { - tenancy()->log(); - - expect(tenancy()->logMode())->toBe(LogMode::SILENT); -}); - -test('the log can be dumped', function (string $method) { - tenancy()->log(); - - $tenant = Tenant::create(); - - tenancy()->initialize($tenant); - - tenancy()->end(); - - $output = []; - tenancy()->$method(function ($data) use (&$output) { - $output = $data; - }); - - assertTenancyInitializedAndEnded($output, $tenant); -})->with([ - 'dump', - 'dd', -]); - -test('tenancy can log events immediately', function () { - // todo implement - pest()->markTestIncomplete(); -}); - -// todo test the different behavior of the methods in different contexts, or get rid of the logic and simplify it - -function assertTenancyInitializedAndEnded(array $log, Tenant $tenant): void -{ - expect($log)->toHaveCount(4); - - expect($log[0]['event'])->toBe(InitializingTenancy::class); - expect($log[0]['tenant'])->toBe($tenant); - expect($log[1]['event'])->toBe(TenancyInitialized::class); - expect($log[1]['tenant'])->toBe($tenant); - - expect($log[2]['event'])->toBe(EndingTenancy::class); - expect($log[2]['tenant'])->toBe($tenant); - expect($log[3]['event'])->toBe(TenancyEnded::class); - expect($log[3]['tenant'])->toBe($tenant); -} From 864add9adef46aa4cb7dc7cc1c61a5d7f28ad79f Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Tue, 31 Jan 2023 03:00:43 +0000 Subject: [PATCH 26/39] Fix code style (php-cs-fixer) --- src/TenancyServiceProvider.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 9d2d56c4..ee34ef1e 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -6,13 +6,10 @@ namespace Stancl\Tenancy; use Illuminate\Cache\CacheManager; use Illuminate\Database\Console\Migrations\FreshCommand; -use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Tenant; -use Stancl\Tenancy\Enums\LogMode; -use Stancl\Tenancy\Events\Contracts\TenancyEvent; use Stancl\Tenancy\Resolvers\DomainTenantResolver; class TenancyServiceProvider extends ServiceProvider From 66c7d6a0662c286bd1cb45e36e1148d8c040d84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 1 Feb 2023 06:05:26 +0100 Subject: [PATCH 27/39] fix PhpParser exception --- .github/workflows/ci.yml | 1 - src/Tenancy.php | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc61273d..314d6e4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.2' - extensions: imagick, swoole - uses: actions/checkout@v2 - name: Install composer dependencies run: composer install diff --git a/src/Tenancy.php b/src/Tenancy.php index 1acd02ad..98373203 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -19,7 +19,7 @@ class Tenancy /** * The current tenant. */ - public (Tenant&Model)|null $tenant = null; + public Tenant|null $tenant = null; // todo docblock public ?Closure $getBootstrappersUsing = null; @@ -111,7 +111,7 @@ class Tenancy /** * Try to find a tenant using an ID. */ - public static function find(int|string $id): (Tenant&Model)|null + public static function find(int|string $id): Tenant|null { $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); From 7767fea5ba8ccdf723b1d2c673146f9fb79d3618 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Wed, 1 Feb 2023 05:05:50 +0000 Subject: [PATCH 28/39] Fix code style (php-cs-fixer) --- src/TenancyServiceProvider.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 9d2d56c4..ee34ef1e 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -6,13 +6,10 @@ namespace Stancl\Tenancy; use Illuminate\Cache\CacheManager; use Illuminate\Database\Console\Migrations\FreshCommand; -use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Tenant; -use Stancl\Tenancy\Enums\LogMode; -use Stancl\Tenancy\Events\Contracts\TenancyEvent; use Stancl\Tenancy\Resolvers\DomainTenantResolver; class TenancyServiceProvider extends ServiceProvider From 0a205dd81721ba92a83f50d7085d5fdff1d775ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 1 Feb 2023 06:17:19 +0100 Subject: [PATCH 29/39] resolve phpstan issues --- phpstan.neon | 6 ++++++ src/Tenancy.php | 1 + 2 files changed, 7 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 7ae06b44..91e9f3af 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -23,6 +23,7 @@ parameters: - src/Commands/ClearPendingTenants.php - src/Database/Concerns/PendingScope.php - src/Database/ParentModelScope.php + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder\:\:withPending\(\)#' - message: '#invalid type Laravel\\Telescope\\IncomingEntry#' paths: @@ -47,9 +48,14 @@ parameters: message: '#Trying to invoke Closure\|null but it might not be a callable#' paths: - src/Database/DatabaseConfig.php + - + message: '#Unable to resolve the template type (TMapWithKeysKey|TMapWithKeysValue) in call to method#' + paths: + - src/Concerns/DealsWithTenantSymlinks.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false # later we may want to enable this treatPhpDocTypesAsCertain: false diff --git a/src/Tenancy.php b/src/Tenancy.php index 98373203..6de52c42 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -113,6 +113,7 @@ class Tenancy */ public static function find(int|string $id): Tenant|null { + /** @var (Tenant&Model)|null */ $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); return $tenant; From 342c67fe022271ac1005fa9d7a2af96781b202cb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 1 Feb 2023 06:55:26 +0100 Subject: [PATCH 30/39] Add skip-failing option to the Migrate command (#945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add and test Migrate command's skip-failing option * Improve naming * Move migration event dispatching inside try block * Change test name * Fix skip-failing test * Use QueryException instead of Exception * Correct TenantDatabaseDoesNotExistException import * Correct test * Check for the the testing env in DB bootstrapper * Correct the Migrate command * Fix code style (php-cs-fixer) * add docs todo * Add QueryException to the Migrat command try/catch * Return status codes in Migrate * Fix code style (php-cs-fixer) * Add test for not stopping tenants:migrate after the first failure * Update Migrate command * Fix code style (php-cs-fixer) * Fix code style (php-cs-fixer) * Use `getTenants()` * Use withtenantDatabases where needed * Add withTenantDatabases to test --------- Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- .../DatabaseTenancyBootstrapper.php | 2 +- src/Commands/Migrate.php | 27 ++++++++---- tests/AutomaticModeTest.php | 6 +++ tests/BatchTest.php | 2 + tests/CommandsTest.php | 42 +++++++++++++++++++ tests/MailTest.php | 4 ++ tests/Pest.php | 11 +++++ tests/QueueTest.php | 21 +++++----- 8 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index c6dba079..f058dc43 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -25,7 +25,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper /** @var TenantWithDatabase $tenant */ // Better debugging, but breaks cached lookup in prod - if (app()->environment('local')) { + if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149 $database = $tenant->database()->getName(); if (! $tenant->database()->manager()->databaseExists($database)) { throw new TenantDatabaseDoesNotExistException($database); diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 0d2fceaa..47b95bd2 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -7,9 +7,11 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Console\Migrations\MigrateCommand; use Illuminate\Database\Migrations\Migrator; +use Illuminate\Database\QueryException; use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\HasTenantOptions; +use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\MigratingDatabase; @@ -28,6 +30,8 @@ class Migrate extends MigrateCommand { parent::__construct($migrator, $dispatcher); + $this->addOption('skip-failing'); + $this->specifyParameters(); } @@ -43,16 +47,23 @@ class Migrate extends MigrateCommand return 1; } - tenancy()->runForMultiple($this->getTenants(), function ($tenant) { - $this->components->info("Tenant: {$tenant->getTenantKey()}"); + foreach ($this->getTenants() as $tenant) { + try { + $tenant->run(function ($tenant) { + $this->line("Tenant: {$tenant->getTenantKey()}"); - event(new MigratingDatabase($tenant)); + event(new MigratingDatabase($tenant)); + // Migrate + parent::handle(); - // Migrate - parent::handle(); - - event(new DatabaseMigrated($tenant)); - }); + event(new DatabaseMigrated($tenant)); + }); + } catch (TenantDatabaseDoesNotExistException|QueryException $th) { + if (! $this->option('skip-failing')) { + throw $th; + } + } + } return 0; } diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php index fc740fc1..1a0948ea 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -50,6 +50,8 @@ test('context is switched when tenancy is reinitialized', function () { }); test('central helper runs callbacks in the central state', function () { + withTenantDatabases(); + tenancy()->initialize($tenant = Tenant::create()); tenancy()->central(function () { @@ -60,6 +62,8 @@ test('central helper runs callbacks in the central state', function () { }); test('central helper returns the value from the callback', function () { + withTenantDatabases(); + tenancy()->initialize(Tenant::create()); pest()->assertSame('foo', tenancy()->central(function () { @@ -68,6 +72,8 @@ test('central helper returns the value from the callback', function () { }); test('central helper reverts back to tenant context', function () { + withTenantDatabases(); + tenancy()->initialize($tenant = Tenant::create()); tenancy()->central(function () { diff --git a/tests/BatchTest.php b/tests/BatchTest.php index 629a4e61..24cb7c59 100644 --- a/tests/BatchTest.php +++ b/tests/BatchTest.php @@ -23,6 +23,8 @@ beforeEach(function () { }); test('batch repository is set to tenant connection and reverted', function () { + withTenantDatabases(); + $tenant = Tenant::create(); $tenant2 = Tenant::create(); diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 444830d1..e5da16b7 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -18,11 +18,13 @@ use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\Tests\Etc\TestSeeder; use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; beforeEach(function () { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { @@ -109,6 +111,46 @@ test('migrate command loads schema state', function () { expect(Schema::hasTable('users'))->toBeTrue(); }); +test('migrate command only throws exceptions if skip-failing is not passed', function() { + Tenant::create(); + + $tenantWithoutDatabase = Tenant::create(); + $databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName()); + + DB::statement("DROP DATABASE `$databaseToDrop`"); + + Tenant::create(); + + expect(fn() => pest()->artisan('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump"'))->toThrow(TenantDatabaseDoesNotExistException::class); + expect(fn() => pest()->artisan('tenants:migrate --schema-path="tests/Etc/tenant-schema.dump" --skip-failing'))->not()->toThrow(TenantDatabaseDoesNotExistException::class); +}); + +test('migrate command does not stop after the first failure if skip-failing is passed', function() { + $tenants = collect([ + Tenant::create(), + $tenantWithoutDatabase = Tenant::create(), + Tenant::create(), + ]); + + $migratedTenants = 0; + + Event::listen(DatabaseMigrated::class, function() use (&$migratedTenants) { + $migratedTenants++; + }); + + $databaseToDrop = $tenantWithoutDatabase->run(fn() => DB::connection()->getDatabaseName()); + + DB::statement("DROP DATABASE `$databaseToDrop`"); + + Artisan::call('tenants:migrate', [ + '--schema-path' => '"tests/Etc/tenant-schema.dump"', + '--skip-failing' => true, + '--tenants' => $tenants->pluck('id')->toArray(), + ]); + + expect($migratedTenants)->toBe(2); +}); + test('dump command works', function () { $tenant = Tenant::create(); $schemaPath = 'tests/Etc/tenant-schema-test.dump'; diff --git a/tests/MailTest.php b/tests/MailTest.php index 544fda1b..c530b7e8 100644 --- a/tests/MailTest.php +++ b/tests/MailTest.php @@ -27,6 +27,8 @@ function assertMailerTransportUsesPassword(string|null $password) { }; test('mailer transport uses the correct credentials', function() { + withTenantDatabases(); + config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']); MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; @@ -52,6 +54,8 @@ test('mailer transport uses the correct credentials', function() { test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() { + withTenantDatabases(); + $mailers = fn() => invade(app(MailManager::class))->mailers; app(MailManager::class)->mailer('smtp'); diff --git a/tests/Pest.php b/tests/Pest.php index d7ca8c22..5380da0a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,10 @@ in(__DIR__); @@ -8,3 +12,10 @@ function pest(): TestCase { return Pest\TestSuite::getInstance()->test; } + +function withTenantDatabases() +{ + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); +} diff --git a/tests/QueueTest.php b/tests/QueueTest.php index c1fa24b8..f88b3934 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -3,23 +3,23 @@ declare(strict_types=1); use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; use Spatie\Valuestore\Valuestore; use Illuminate\Support\Facades\DB; use Stancl\Tenancy\Tests\Etc\User; use Stancl\JobPipeline\JobPipeline; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Event; +use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; +use Illuminate\Queue\InteractsWithQueue; use Stancl\Tenancy\Events\TenantCreated; use Illuminate\Database\Schema\Blueprint; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; @@ -48,6 +48,8 @@ afterEach(function () { }); test('tenant id is passed to tenant queues', function () { + withTenantDatabases(); + config(['queue.default' => 'sync']); $tenant = Tenant::create(); @@ -64,6 +66,8 @@ test('tenant id is passed to tenant queues', function () { }); test('tenant id is not passed to central queues', function () { + withTenantDatabases(); + $tenant = Tenant::create(); tenancy()->initialize($tenant); @@ -156,6 +160,8 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan })->with([true, false]); test('the tenant used by the job doesnt change when the current tenant changes', function () { + withTenantDatabases(); + $tenant1 = Tenant::create([ 'id' => 'acme', ]); @@ -217,13 +223,6 @@ function withUsers() }); } -function withTenantDatabases() -{ - Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener()); -} - class TestJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; From 087733d5dbd3ea3c53c38bb927905fb9e9c6be8e Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Wed, 1 Feb 2023 11:02:03 +0500 Subject: [PATCH 31/39] Allow defining the tenant connection template using array syntax in config (#1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `template_tenant_connection` can be array or string * Update TenantDatabaseManagerTest.php * Update TenantDatabaseManagerTest.php * Update TenantDatabaseManagerTest.php * Update DatabaseConfig.php * partial database config for template * Update tests/TenantDatabaseManagerTest.php Co-authored-by: lukinovec * update test name * improve test names * add comments --------- Co-authored-by: lukinovec Co-authored-by: Samuel Štancl --- src/Database/DatabaseConfig.php | 42 ++++++++++------ tests/TenantDatabaseManagerTest.php | 75 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 309d828f..52cb464c 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -87,7 +87,7 @@ class DatabaseConfig { $this->tenant->setInternal('db_name', $this->getName()); - if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) { + if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); } @@ -97,11 +97,29 @@ class DatabaseConfig } } - public function getTemplateConnectionName(): string + public function getTemplateConnectionDriver(): string { - return $this->tenant->getInternal('db_connection') - ?? config('tenancy.database.template_tenant_connection') - ?? config('tenancy.database.central_connection'); + return $this->getTemplateConnection()['driver']; + } + + public function getTemplateConnection(): array + { + if ($template = $this->tenant->getInternal('db_connection')) { + return config("database.connections.{$template}"); + } + + if ($template = config('tenancy.database.template_tenant_connection')) { + return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}"); + } + + return $this->getCentralConnection(); + } + + protected function getCentralConnection(): array + { + $centralConnectionName = config('tenancy.database.central_connection'); + + return config("database.connections.{$centralConnectionName}"); } public function getTenantHostConnectionName(): string @@ -114,8 +132,7 @@ class DatabaseConfig */ public function connection(): array { - $template = $this->getTemplateConnectionName(); - $templateConnection = config("database.connections.{$template}"); + $templateConnection = $this->getTemplateConnection(); return $this->manager()->makeConnectionConfig( array_merge($templateConnection, $this->tenantConfig()), @@ -129,10 +146,9 @@ class DatabaseConfig public function hostConnection(): array { $config = $this->tenantConfig(); - $template = $this->getTemplateConnectionName(); - $templateConnection = config("database.connections.{$template}"); + $templateConnection = $this->getTemplateConnection(); - if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) { + if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { // We're removing the username and password because user with these credentials is not created yet // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, // consider creating a new connection and use it as `tenancy_db_connection` tenant config key @@ -196,7 +212,7 @@ class DatabaseConfig $tenantHostConnectionName = $this->getTenantHostConnectionName(); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); - $manager = $this->connectionDriverManager($tenantHostConnectionName); + $manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver")); if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { $manager->setConnection($tenantHostConnectionName); @@ -211,10 +227,8 @@ class DatabaseConfig * * @throws DatabaseManagerNotRegisteredException */ - protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager + protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager { - $driver = config("database.connections.{$connectionName}.driver"); - $databaseManagers = config('tenancy.database.managers'); if (! array_key_exists($driver, $databaseManagers)) { diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 19b74e21..5d9a15d6 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -390,6 +390,81 @@ test('path used by sqlite manager can be customized', function () { expect(file_exists($customPath . '/' . $name))->toBeTrue(); }); +test('the tenant connection template can be specified either by name or as a connection array', function () { + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + config([ + 'tenancy.database.managers.mysql' => MySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', + ]); + + $name = 'foo' . Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + expect($manager->databaseExists($name))->toBeTrue(); + expect($manager->database()->getConfig('host'))->toBe('mysql'); + + config([ + 'tenancy.database.template_tenant_connection' => [ + 'driver' => 'mysql', + 'url' => null, + 'host' => 'mysql2', + 'port' => '3306', + 'database' => 'main', + 'username' => 'root', + 'password' => 'password', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => [], + ], + ]); + + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works + expect($manager->database()->getConfig('host'))->toBe('mysql2'); +}); + +test('partial tenant connection templates get merged into the central connection template', function () { + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + config([ + 'database.connections.central.url' => 'example.com', + 'tenancy.database.template_tenant_connection' => [ + 'url' => null, + 'host' => 'mysql2', + ], + ]); + + $name = 'foo' . Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + expect($manager->databaseExists($name))->toBeTrue(); // tenant connection works + expect($manager->database()->getConfig('host'))->toBe('mysql2'); + expect($manager->database()->getConfig('url'))->toBeNull(); +}); + // Datasets dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], From 758fbc8a750fd394611601c029e4aeccae3cdb5d Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Thu, 2 Feb 2023 10:39:35 +0500 Subject: [PATCH 32/39] Use polymorphic table for mapping resources to tenants (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Fix code style (php-cs-fixer) * adjust tests * Update ResourceSyncingPolymorphicTest.php * Update SyncMaster.php * correct method name * Update ResourceSyncingPolymorphicTest.php * use BelongsToMany return type * separate pivot model for each approach * ability to publish migrations * remove unsed import * use resource migrations from asset * anonymous migration for `tenant_resources` table * rename file * rename classes * trait * add back using statement * revert to unset change * use unset approach * use unset approach * Assert `tenants` are accessible * Update ResourceSyncingUsingPolymorphicTest.php * improve `tenants` assertions * improve assertions * remove `getResourceTenantModelName` method and use config * use `BelongsToMany` for `tenants` method return type * Fix code style (php-cs-fixer) * revert type * use correct key * test right resources are accessible from the tenant * Update tests/ResourceSyncingUsingPolymorphicTest.php --------- Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- ...1_000002_create_tenant_resources_table.php | 25 ++ src/Database/Concerns/ResourceSyncing.php | 8 + src/Database/Concerns/TriggerSyncEvent.php | 21 + src/Database/Models/TenantMorphPivot.php | 13 + src/Database/Models/TenantPivot.php | 13 +- src/TenancyServiceProvider.php | 4 + ..._11_000001_test_create_companies_table.php | 30 ++ tests/ResourceSyncingTest.php | 2 +- tests/ResourceSyncingUsingPolymorphicTest.php | 398 ++++++++++++++++++ 9 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php create mode 100644 src/Database/Concerns/TriggerSyncEvent.php create mode 100644 src/Database/Models/TenantMorphPivot.php create mode 100644 tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php create mode 100644 tests/ResourceSyncingUsingPolymorphicTest.php diff --git a/assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php b/assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php new file mode 100644 index 00000000..3e8ef18f --- /dev/null +++ b/assets/resource-syncing-migrations/2020_05_11_000002_create_tenant_resources_table.php @@ -0,0 +1,25 @@ +increments('id'); + $table->string('tenant_id'); + $table->string('resource_global_id'); + $table->string('tenant_resources_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_resources'); + } +}; diff --git a/src/Database/Concerns/ResourceSyncing.php b/src/Database/Concerns/ResourceSyncing.php index ea9f83b4..9caacda5 100644 --- a/src/Database/Concerns/ResourceSyncing.php +++ b/src/Database/Concerns/ResourceSyncing.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use Stancl\Tenancy\Contracts\Syncable; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; +use Stancl\Tenancy\Database\Models\TenantMorphPivot; use Stancl\Tenancy\Events\SyncedResourceSaved; trait ResourceSyncing @@ -43,4 +45,10 @@ trait ResourceSyncing { return true; } + + public function tenants(): MorphToMany + { + return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', 'global_id') + ->using(TenantMorphPivot::class); + } } diff --git a/src/Database/Concerns/TriggerSyncEvent.php b/src/Database/Concerns/TriggerSyncEvent.php new file mode 100644 index 00000000..13207762 --- /dev/null +++ b/src/Database/Concerns/TriggerSyncEvent.php @@ -0,0 +1,21 @@ +pivotParent; + + if ($parent instanceof Syncable && $parent->shouldSync()) { + $parent->triggerSyncEvent(); + } + }); + } +} diff --git a/src/Database/Models/TenantMorphPivot.php b/src/Database/Models/TenantMorphPivot.php new file mode 100644 index 00000000..b10d9d32 --- /dev/null +++ b/src/Database/Models/TenantMorphPivot.php @@ -0,0 +1,13 @@ +pivotParent; - - if ($parent instanceof Syncable && $parent->shouldSync()) { - $parent->triggerSyncEvent(); - } - }); - } + use TriggerSyncEvent; } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index ee34ef1e..23fb6473 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -106,6 +106,10 @@ class TenancyServiceProvider extends ServiceProvider __DIR__ . '/../assets/impersonation-migrations/' => database_path('migrations'), ], 'impersonation-migrations'); + $this->publishes([ + __DIR__ . '/../assets/resource-syncing-migrations/' => database_path('migrations'), + ], 'resource-syncing-migrations'); + $this->publishes([ __DIR__ . '/../assets/tenant_routes.stub.php' => base_path('routes/tenant.php'), ], 'routes'); diff --git a/tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php b/tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php new file mode 100644 index 00000000..2d61a45d --- /dev/null +++ b/tests/Etc/synced_resource_migrations/companies/2020_05_11_000001_test_create_companies_table.php @@ -0,0 +1,30 @@ +increments('id'); + $table->string('global_id')->unique(); + $table->string('name'); + $table->string('email'); + }); + } + + public function down() + { + Schema::dropIfExists('companies'); + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 811b8d1a..a988178e 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -832,7 +832,7 @@ function migrateUsersTableForTenants(): void // Tenant model used for resource syncing setup class ResourceTenant extends Tenant { - public function users() + public function users(): BelongsToMany { return $this->belongsToMany(CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id') ->using(TenantPivot::class); diff --git a/tests/ResourceSyncingUsingPolymorphicTest.php b/tests/ResourceSyncingUsingPolymorphicTest.php new file mode 100644 index 00000000..408fd4ef --- /dev/null +++ b/tests/ResourceSyncingUsingPolymorphicTest.php @@ -0,0 +1,398 @@ + [ + DatabaseTenancyBootstrapper::class, + ], + 'tenancy.models.tenant' => ResourceTenantUsingPolymorphic::class, + ]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + DatabaseConfig::generateDatabaseNamesUsing(function () { + return 'db' . Str::random(16); + }); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + UpdateSyncedResource::$shouldQueue = false; // Global state cleanup + Event::listen(SyncedResourceSaved::class, UpdateSyncedResource::class); + + // Run migrations on central connection + pest()->artisan('migrate', [ + '--path' => [ + __DIR__ . '/../assets/resource-syncing-migrations', + __DIR__ . '/Etc/synced_resource_migrations/users', + __DIR__ . '/Etc/synced_resource_migrations/companies', + ], + '--realpath' => true, + ])->assertExitCode(0); +}); + +test('resource syncing works using a single pivot table for multiple models when syncing from central to tenant', function () { + $tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']); + migrateUsersTableForTenants(); + + $centralUser = CentralUserUsingPolymorphic::create([ + 'global_id' => 'acme', + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + $tenant1->run(function () { + expect(TenantUserUsingPolymorphic::all())->toHaveCount(0); + }); + + $centralUser->tenants()->attach('t1'); + + // Assert `tenants` are accessible + expect($centralUser->tenants->pluck('id')->toArray())->toBe(['t1']); + + // Users are accessible from tenant + expect($tenant1->users()->pluck('email')->toArray())->toBe(['john@localhost']); + + // Assert User resource is synced + $tenant1->run(function () use ($centralUser) { + $tenantUser = TenantUserUsingPolymorphic::first()->toArray(); + $centralUser = $centralUser->withoutRelations()->toArray(); + unset($centralUser['id'], $tenantUser['id']); + + expect($tenantUser)->toBe($centralUser); + }); + + $tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']); + migrateCompaniesTableForTenants(); + + $centralCompany = CentralCompanyUsingPolymorphic::create([ + 'global_id' => 'acme', + 'name' => 'ArchTech', + 'email' => 'archtech@localhost', + ]); + + $tenant2->run(function () { + expect(TenantCompanyUsingPolymorphic::all())->toHaveCount(0); + }); + + $centralCompany->tenants()->attach('t2'); + + // Assert `tenants` are accessible + expect($centralCompany->tenants->pluck('id')->toArray())->toBe(['t2']); + + // Companies are accessible from tenant + expect($tenant2->companies()->pluck('email')->toArray())->toBe(['archtech@localhost']); + + // Assert Company resource is synced + $tenant2->run(function () use ($centralCompany) { + $tenantCompany = TenantCompanyUsingPolymorphic::first()->toArray(); + $centralCompany = $centralCompany->withoutRelations()->toArray(); + + unset($centralCompany['id'], $tenantCompany['id']); + + expect($tenantCompany)->toBe($centralCompany); + }); +}); + +test('resource syncing works using a single pivot table for multiple models when syncing from tenant to central', function () { + $tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']); + migrateUsersTableForTenants(); + + tenancy()->initialize($tenant1); + + $tenantUser = TenantUserUsingPolymorphic::create([ + 'name' => 'John Doe', + 'email' => 'john@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + tenancy()->end(); + + // Assert User resource is synced + $centralUser = CentralUserUsingPolymorphic::first(); + + // Assert `tenants` are accessible + expect($centralUser->tenants->pluck('id')->toArray())->toBe(['t1']); + + // Users are accessible from tenant + expect($tenant1->users()->pluck('email')->toArray())->toBe(['john@localhost']); + + $centralUser = $centralUser->withoutRelations()->toArray(); + $tenantUser = $tenantUser->toArray(); + unset($centralUser['id'], $tenantUser['id']); + + // array keys use a different order here + expect($tenantUser)->toEqualCanonicalizing($centralUser); + + $tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']); + migrateCompaniesTableForTenants(); + + tenancy()->initialize($tenant2); + + $tenantCompany = TenantCompanyUsingPolymorphic::create([ + 'global_id' => 'acme', + 'name' => 'tenant comp', + 'email' => 'company@localhost', + ]); + + tenancy()->end(); + + // Assert Company resource is synced + $centralCompany = CentralCompanyUsingPolymorphic::first(); + + // Assert `tenants` are accessible + expect($centralCompany->tenants->pluck('id')->toArray())->toBe(['t2']); + + // Companies are accessible from tenant + expect($tenant2->companies()->pluck('email')->toArray())->toBe(['company@localhost']); + + $centralCompany = $centralCompany->withoutRelations()->toArray(); + $tenantCompany = $tenantCompany->toArray(); + unset($centralCompany['id'], $tenantCompany['id']); + + expect($tenantCompany)->toBe($centralCompany); +}); + +test('right resources are accessible from the tenant', function () { + $tenant1 = ResourceTenantUsingPolymorphic::create(['id' => 't1']); + $tenant2 = ResourceTenantUsingPolymorphic::create(['id' => 't2']); + migrateUsersTableForTenants(); + + $user1 = CentralUserUsingPolymorphic::create([ + 'global_id' => 'user1', + 'name' => 'user1', + 'email' => 'user1@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + $user2 = CentralUserUsingPolymorphic::create([ + 'global_id' => 'user2', + 'name' => 'user2', + 'email' => 'user2@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + $user3 = CentralUserUsingPolymorphic::create([ + 'global_id' => 'user3', + 'name' => 'user3', + 'email' => 'user3@localhost', + 'password' => 'password', + 'role' => 'commenter', + ]); + + $user1->tenants()->attach('t1'); + $user2->tenants()->attach('t1'); + $user3->tenants()->attach('t2'); + + expect($tenant1->users()->pluck('email')->toArray())->toBe([$user1->email, $user2->email]); + expect($tenant2->users()->pluck('email')->toArray())->toBe([$user3->email]); +}); + +function migrateCompaniesTableForTenants(): void +{ + pest()->artisan('tenants:migrate', [ + '--path' => __DIR__ . '/Etc/synced_resource_migrations/companies', + '--realpath' => true, + ])->assertExitCode(0); +} + +// Tenant model used for resource syncing setup +class ResourceTenantUsingPolymorphic extends Tenant +{ + public function users(): MorphToMany + { + return $this->morphedByMany(CentralUserUsingPolymorphic::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id') + ->using(TenantMorphPivot::class); + } + + public function companies(): MorphToMany + { + return $this->morphedByMany(CentralCompanyUsingPolymorphic::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id') + ->using(TenantMorphPivot::class); + } +} + +class CentralUserUsingPolymorphic extends Model implements SyncMaster +{ + use ResourceSyncing, CentralConnection; + + protected $guarded = []; + + public $timestamps = false; + + public $table = 'users'; + + public function getTenantModelName(): string + { + return TenantUserUsingPolymorphic::class; + } + + public function getGlobalIdentifierKey(): string|int + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getCentralModelName(): string + { + return static::class; + } + + public function getSyncedAttributeNames(): array + { + return [ + 'global_id', + 'name', + 'password', + 'email', + ]; + } +} + +class TenantUserUsingPolymorphic extends Model implements Syncable +{ + use ResourceSyncing; + + protected $table = 'users'; + + protected $guarded = []; + + public $timestamps = false; + + public function getGlobalIdentifierKey(): string|int + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getCentralModelName(): string + { + return CentralUserUsingPolymorphic::class; + } + + public function getSyncedAttributeNames(): array + { + return [ + 'global_id', + 'name', + 'password', + 'email', + ]; + } +} + +class CentralCompanyUsingPolymorphic extends Model implements SyncMaster +{ + use ResourceSyncing, CentralConnection; + + protected $guarded = []; + + public $timestamps = false; + + public $table = 'companies'; + + public function getTenantModelName(): string + { + return TenantCompanyUsingPolymorphic::class; + } + + public function getGlobalIdentifierKey(): string|int + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getCentralModelName(): string + { + return static::class; + } + + public function getSyncedAttributeNames(): array + { + return [ + 'global_id', + 'name', + 'email', + ]; + } +} + +class TenantCompanyUsingPolymorphic extends Model implements Syncable +{ + use ResourceSyncing; + + protected $table = 'companies'; + + protected $guarded = []; + + public $timestamps = false; + + public function getGlobalIdentifierKey(): string|int + { + return $this->getAttribute($this->getGlobalIdentifierKeyName()); + } + + public function getGlobalIdentifierKeyName(): string + { + return 'global_id'; + } + + public function getCentralModelName(): string + { + return CentralCompanyUsingPolymorphic::class; + } + + public function getSyncedAttributeNames(): array + { + return [ + 'global_id', + 'name', + 'email', + ]; + } +} + From a006e498816c16444b65be8537534f360c97b9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 16 Feb 2023 17:20:55 +0100 Subject: [PATCH 33/39] specify version of odbc libraries --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5dfe442c..421e43d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN apt-get update \ && curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ && curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list \ && apt-get update \ - && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql17 + && ACCEPT_EULA=Y apt-get install -y unixodbc-dev=2.3.7 unixodbc=2.3.7 odbcinst1debian2=2.3.7 odbcinst=2.3.7 msodbcsql17 # set PHP version RUN update-alternatives --set php /usr/bin/php$PHP_VERSION \ From 617e9a7a7392705d058e4f030c68795d30a8a93f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 17 Feb 2023 10:56:43 +0100 Subject: [PATCH 34/39] [4.x] Allow user to customize tenant's URL root in CLI (#1044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add UrlTenancyBootstrapper * Fix code style (php-cs-fixer) * Move URL overriding to a separate method, call it in `boot()` * Test URL root overriding * Change parameter formatting Co-authored-by: Samuel Štancl * Fix code style (php-cs-fixer) * Improve URL bootstrapper test * Move `$scheme` and `$hostname` to the closure * Update code example comment * Hardcode values instead of referencing variables * Delete extra line --------- Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- assets/TenancyServiceProvider.stub.php | 26 ++++++++--- assets/config.php | 1 + src/Bootstrappers/UrlTenancyBootstrapper.php | 35 +++++++++++++++ tests/BootstrapperTest.php | 46 +++++++++++++++++++- 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/Bootstrappers/UrlTenancyBootstrapper.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 6735b37f..a2679061 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace App\Providers; +use Stancl\Tenancy\Jobs; +use Stancl\Tenancy\Events; +use Stancl\Tenancy\Listeners; +use Stancl\Tenancy\Middleware; +use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use Stancl\JobPipeline\JobPipeline; -use Stancl\Tenancy\Events; -use Stancl\Tenancy\Jobs; -use Stancl\Tenancy\Listeners; -use Stancl\Tenancy\Middleware; class TenancyServiceProvider extends ServiceProvider { @@ -118,6 +118,21 @@ class TenancyServiceProvider extends ServiceProvider ]; } + protected function overrideUrlInTenantContext(): void + { + /** + * Example of CLI tenant URL root override: + * + * UrlTenancyBootstrapper::$rootUrlOverride = function (Tenant $tenant) { + * $baseUrl = url('/'); + * $scheme = str($baseUrl)->before('://'); + * $hostname = str($baseUrl)->after($scheme . '://'); + * + * return $scheme . '://' . $tenant->getTenantKey() . '.' . $hostname; + *}; + */ + } + public function register() { // @@ -129,6 +144,7 @@ class TenancyServiceProvider extends ServiceProvider $this->mapRoutes(); $this->makeTenancyMiddlewareHighestPriority(); + $this->overrideUrlInTenantContext(); } protected function bootEvents() diff --git a/assets/config.php b/assets/config.php index c6f3e5a9..bbfa9974 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\UrlTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\SessionTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper::class, // Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed diff --git a/src/Bootstrappers/UrlTenancyBootstrapper.php b/src/Bootstrappers/UrlTenancyBootstrapper.php new file mode 100644 index 00000000..0a4122a6 --- /dev/null +++ b/src/Bootstrappers/UrlTenancyBootstrapper.php @@ -0,0 +1,35 @@ +originalRootUrl = $this->urlGenerator->to('/'); + + if (static::$rootUrlOverride) { + $this->urlGenerator->forceRootUrl((static::$rootUrlOverride)($tenant)); + } + } + + public function revert(): void + { + $this->urlGenerator->forceRootUrl($this->originalRootUrl); + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index 3cc50b58..fc2d4709 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -3,14 +3,15 @@ declare(strict_types=1); use Illuminate\Support\Str; -use Illuminate\Mail\MailManager; 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\Storage; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; @@ -24,9 +25,11 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; +use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; @@ -380,3 +383,44 @@ function getDiskPrefix(string $disk): string return $prefix; } + +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.url' => UrlTenancyBootstrapper::class]); + + Route::group([ + 'middleware' => InitializeTenancyBySubdomain::class, + ], function () { + Route::get('/', function () { + return true; + })->name('home'); + }); + + $baseUrl = url(route('home')); + + $rootUrlOverride = function (Tenant $tenant) use ($baseUrl) { + $scheme = str($baseUrl)->before('://'); + $hostname = str($baseUrl)->after($scheme . '://'); + + return $scheme . '://' . $tenant->getTenantKey() . '.' . $hostname; + }; + + UrlTenancyBootstrapper::$rootUrlOverride = $rootUrlOverride; + + $tenant = Tenant::create(); + $tenantUrl = $rootUrlOverride($tenant); + + expect($tenantUrl)->not()->toBe($baseUrl); + + expect(url(route('home')))->toBe($baseUrl); + expect(URL::to('/'))->toBe($baseUrl); + + tenancy()->initialize($tenant); + + expect(url(route('home')))->toBe($tenantUrl); + expect(URL::to('/'))->toBe($tenantUrl); + + tenancy()->end(); + + expect(url(route('home')))->toBe($baseUrl); + expect(URL::to('/'))->toBe($baseUrl); +}); From fbdb13f392ba137f8e771099574beb4dcb6cfa0c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 18 Feb 2023 13:01:17 +0100 Subject: [PATCH 35/39] [4.x] Set `app.url` config in UrlTenancyBootstrapper (#1068) * Set `app.url` config in UrlTenancyBootstrapper * Add assertions * Set base app URL in config * Make UrlTenancyBootstrapper a singleton --- src/Bootstrappers/UrlTenancyBootstrapper.php | 8 +++++++- tests/BootstrapperTest.php | 4 ++++ tests/TestCase.php | 16 +++++++++------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Bootstrappers/UrlTenancyBootstrapper.php b/src/Bootstrappers/UrlTenancyBootstrapper.php index 0a4122a6..db27c8c5 100644 --- a/src/Bootstrappers/UrlTenancyBootstrapper.php +++ b/src/Bootstrappers/UrlTenancyBootstrapper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Closure; +use Illuminate\Config\Repository; use Illuminate\Contracts\Routing\UrlGenerator; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -16,6 +17,7 @@ class UrlTenancyBootstrapper implements TenancyBootstrapper public function __construct( protected UrlGenerator $urlGenerator, + protected Repository $config, ) { } @@ -24,12 +26,16 @@ class UrlTenancyBootstrapper implements TenancyBootstrapper $this->originalRootUrl = $this->urlGenerator->to('/'); if (static::$rootUrlOverride) { - $this->urlGenerator->forceRootUrl((static::$rootUrlOverride)($tenant)); + $newRootUrl = (static::$rootUrlOverride)($tenant); + + $this->urlGenerator->forceRootUrl($newRootUrl); + $this->config->set('app.url', $newRootUrl); } } public function revert(): void { $this->urlGenerator->forceRootUrl($this->originalRootUrl); + $this->config->set('app.url', $this->originalRootUrl); } } diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index fc2d4709..fbc4f0b3 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -396,6 +396,7 @@ test('url bootstrapper overrides the root url when tenancy gets initialized and }); $baseUrl = url(route('home')); + config(['app.url' => $baseUrl]); $rootUrlOverride = function (Tenant $tenant) use ($baseUrl) { $scheme = str($baseUrl)->before('://'); @@ -413,14 +414,17 @@ test('url bootstrapper overrides the root url when tenancy gets initialized and expect(url(route('home')))->toBe($baseUrl); expect(URL::to('/'))->toBe($baseUrl); + expect(config('app.url'))->toBe($baseUrl); tenancy()->initialize($tenant); expect(url(route('home')))->toBe($tenantUrl); expect(URL::to('/'))->toBe($tenantUrl); + expect(config('app.url'))->toBe($tenantUrl); tenancy()->end(); expect(url(route('home')))->toBe($baseUrl); expect(URL::to('/'))->toBe($baseUrl); + expect(config('app.url'))->toBe($baseUrl); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 07af199f..f6589688 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,17 +4,17 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; -use Dotenv\Dotenv; -use Illuminate\Foundation\Application; -use Illuminate\Support\Facades\Redis; use PDO; -use Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper; -use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; -use Stancl\Tenancy\Facades\GlobalCache; +use Dotenv\Dotenv; use Stancl\Tenancy\Facades\Tenancy; -use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\Tests\Etc\Tenant; +use Illuminate\Support\Facades\Redis; +use Illuminate\Foundation\Application; +use Stancl\Tenancy\Facades\GlobalCache; +use Stancl\Tenancy\TenancyServiceProvider; +use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -106,6 +106,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase ], 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class, + 'tenancy.bootstrappers.url' => UrlTenancyBootstrapper::class, 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, @@ -116,6 +117,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration $app->singleton(MailTenancyBootstrapper::class); + $app->singleton(UrlTenancyBootstrapper::class); } protected function getPackageProviders($app) From d7a4982cd3f85b1718cca5a9acce83a3cc4fec7c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 18 Feb 2023 15:52:55 +0100 Subject: [PATCH 36/39] [4.x] Make broadcasting work with Tenancy (#1027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add BroadcastTenancyBootstrapper and TenancyBroadcastManager * Fix code style (php-cs-fixer) * Bind original BroadcastManager again on `revert()` * Fix code style (php-cs-fixer) * Move manager to correct directory * Fix property type * Make BroadcastTenancyBootstrapper a singleton in tests * Fix code style (php-cs-fixer) * Bind the original broadcaster instance on `revert()` * Instead of just forgetting the old broadcaster instance, bind the new one * Add BroadcastTenancyBootstrapper tests * Separate the test * Fix code style (php-cs-fixer) * Add bootstrapper test * Add broadcaster channels test * Clean up BootstrapperTest * Fix BroadcastingTest * Add comments to TenancyBroadcastManager * Add BroadcastTenancyBootstrapper comments * Simplify BroadcastManager extension, remove setDriver method * Add comment * Fix PHPStan errors * Fix PHPStan errors * Remove duplicate import * Fix test * Delete `::class` from test name Co-authored-by: Samuel Štancl * Create databases for newly created tenants in BroadcastingTest * move spatie/invade to require --------- Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- composer.json | 3 +- .../BroadcastTenancyBootstrapper.php | 95 +++++++++++++++++++ src/TenancyBroadcastManager.php | 65 +++++++++++++ tests/BootstrapperTest.php | 80 ++++++++++++++++ tests/BroadcastingTest.php | 65 +++++++++++++ tests/Etc/TestingBroadcaster.php | 25 +++++ tests/TestCase.php | 5 +- 7 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 src/Bootstrappers/BroadcastTenancyBootstrapper.php create mode 100644 src/TenancyBroadcastManager.php create mode 100644 tests/BroadcastingTest.php create mode 100644 tests/Etc/TestingBroadcaster.php diff --git a/composer.json b/composer.json index 0ca231c4..bb11040e 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "spatie/ignition": "^1.4", "ramsey/uuid": "^4.0", "stancl/jobpipeline": "^1.0", - "stancl/virtualcolumn": "^1.3" + "stancl/virtualcolumn": "^1.3", + "spatie/invade": "^1.1" }, "require-dev": { "laravel/framework": "^9.38", diff --git a/src/Bootstrappers/BroadcastTenancyBootstrapper.php b/src/Bootstrappers/BroadcastTenancyBootstrapper.php new file mode 100644 index 00000000..2f625437 --- /dev/null +++ b/src/Bootstrappers/BroadcastTenancyBootstrapper.php @@ -0,0 +1,95 @@ + 'tenant_property', + * ] + */ + public static array $credentialsMap = []; + + public static string|null $broadcaster = null; + + protected array $originalConfig = []; + protected BroadcastManager|null $originalBroadcastManager = null; + protected Broadcaster|null $originalBroadcaster = null; + + public static array $mapPresets = [ + 'pusher' => [ + 'broadcasting.connections.pusher.key' => 'pusher_key', + 'broadcasting.connections.pusher.secret' => 'pusher_secret', + 'broadcasting.connections.pusher.app_id' => 'pusher_app_id', + 'broadcasting.connections.pusher.options.cluster' => 'pusher_cluster', + ], + 'ably' => [ + 'broadcasting.connections.ably.key' => 'ably_key', + 'broadcasting.connections.ably.public' => 'ably_public', + ], + ]; + + public function __construct( + protected Repository $config, + protected Application $app + ) { + static::$broadcaster ??= $config->get('broadcasting.default'); + static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []); + } + + public function bootstrap(Tenant $tenant): void + { + $this->originalBroadcastManager = $this->app->make(BroadcastManager::class); + $this->originalBroadcaster = $this->app->make(Broadcaster::class); + + $this->setConfig($tenant); + + // Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials + $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { + return new TenancyBroadcastManager($this->app); + }); + } + + public function revert(): void + { + // Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy + $this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager); + $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); + + $this->unsetConfig(); + } + + protected function setConfig(Tenant $tenant): void + { + foreach (static::$credentialsMap as $configKey => $storageKey) { + $override = $tenant->$storageKey; + + if (array_key_exists($storageKey, $tenant->getAttributes())) { + $this->originalConfig[$configKey] ??= $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + + protected function unsetConfig(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} diff --git a/src/TenancyBroadcastManager.php b/src/TenancyBroadcastManager.php new file mode 100644 index 00000000..59e30b57 --- /dev/null +++ b/src/TenancyBroadcastManager.php @@ -0,0 +1,65 @@ +resolve() (even when they're + * cached and available in the $broadcasters property). + * + * The reason for recreating the broadcasters is + * to make your app use the correct broadcaster credentials when tenancy is initialized. + */ + public static array $tenantBroadcasters = ['pusher', 'ably']; + + /** + * Override the get method so that the broadcasters in $tenantBroadcasters + * always get freshly resolved even when they're cached and available in the $broadcasters property, + * and that the resolved broadcaster will override the BroadcasterContract::class singleton. + * + * If there's a cached broadcaster with the same name as $name, + * give its channels to the newly resolved bootstrapper. + */ + protected function get($name) + { + if (in_array($name, static::$tenantBroadcasters)) { + /** @var Broadcaster|null $originalBroadcaster */ + $originalBroadcaster = $this->app->make(BroadcasterContract::class); + $newBroadcaster = $this->resolve($name); + + // If there is a current broadcaster, give its channels to the newly resolved one + // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract + // Which doesn't require the channels property + // So passing the channels is only needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances + if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { + $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); + } + + $this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster); + + return $newBroadcaster; + } + + return parent::get($name); + } + + // Because, unlike the original broadcaster, the newly resolved broadcaster won't have the channels registered using routes/channels.php + // Using it for broadcasting won't work, unless we make it have the original broadcaster's channels + protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void + { + // invade() because channels can't be retrieved through any of the broadcaster's public methods + $originalBroadcaster = invade($originalBroadcaster); + + foreach ($originalBroadcaster->channels as $channel => $callback) { + $newBroadcaster->channel($channel, $callback, $originalBroadcaster->retrieveChannelOptions($channel)); + } + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index fbc4f0b3..7350f0a8 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -18,11 +18,14 @@ use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\TenancyBroadcastManager; use Illuminate\Filesystem\FilesystemAdapter; +use Illuminate\Broadcasting\BroadcastManager; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Jobs\CreateStorageSymlinks; use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; @@ -31,6 +34,7 @@ use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; beforeEach(function () { @@ -331,6 +335,82 @@ test('local storage public urls are generated correctly', function() { expect(File::isDirectory($tenantStoragePath))->toBeFalse(); }); +test('BroadcastTenancyBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { + 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('BroadcastTenancyBootstrapper 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', + ]); + + BroadcastTenancyBootstrapper::$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('BroadcastTenancyBootstrapper 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', + ]); + + TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; + BroadcastTenancyBootstrapper::$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() { MailTenancyBootstrapper::$credentialsMap = [ 'mail.mailers.smtp.username' => 'smtp_username', diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php new file mode 100644 index 00000000..aeb70de2 --- /dev/null +++ b/tests/BroadcastingTest.php @@ -0,0 +1,65 @@ + 'null']); + TenancyBroadcastManager::$tenantBroadcasters[] = 'null'; + + $originalBroadcaster = app(BroadcasterContract::class); + + tenancy()->initialize(Tenant::create()); + + // TenancyBroadcastManager binds new broadcaster + $tenantBroadcaster = app(BroadcastManager::class)->driver(); + + expect($tenantBroadcaster)->not()->toBe($originalBroadcaster); + + tenancy()->end(); + + expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); +}); + +test('new broadcasters get the channels from the previously bound broadcaster', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + ]); + + TenancyBroadcastManager::$tenantBroadcasters[] = $driver; + + $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); + $getCurrentChannels = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); + + $registerTestingBroadcaster(); + Broadcast::channel($channel = 'testing-channel', fn() => true); + + expect($channel)->toBeIn($getCurrentChannels()); + + tenancy()->initialize(Tenant::create()); + $registerTestingBroadcaster(); + + expect($channel)->toBeIn($getCurrentChannels()); + + tenancy()->end(); + $registerTestingBroadcaster(); + + expect($channel)->toBeIn($getCurrentChannels()); +}); diff --git a/tests/Etc/TestingBroadcaster.php b/tests/Etc/TestingBroadcaster.php new file mode 100644 index 00000000..23efb74c --- /dev/null +++ b/tests/Etc/TestingBroadcaster.php @@ -0,0 +1,25 @@ + true, ], 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.broadcast' => BroadcastTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class, 'tenancy.bootstrappers.url' => UrlTenancyBootstrapper::class, 'queue.connections.central' => [ @@ -116,6 +118,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase ]); $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration + $app->singleton(BroadcastTenancyBootstrapper::class); $app->singleton(MailTenancyBootstrapper::class); $app->singleton(UrlTenancyBootstrapper::class); } From e61a26d6048519f58193e6c85483ebb4285bade0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 20 Feb 2023 23:47:10 +0100 Subject: [PATCH 37/39] Add L10 support to 4.x (merge 3.x to master) (#1071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * exclude master from CI * Add space after 'up' in 'docker-compose up-d' (#900) * Fix ArgumentCountError on the TenantAssetsController (#894) * Fix ArgumentCount exception on the TenantAssetsController when no `$path` is provided * CS * CS * Handle null case explicitly * code style Co-authored-by: Bram Wubs Co-authored-by: Samuel Štancl * Add support for nested tenant config override (#920) * feat: add support for nested tenant config override * test: ensure nested tenant values are mapped * fix: typo mistake (#954) * [3.x] Add Vite helper for tenancy (#956) * Add Vite helper for tenancy * Move Vite bundler to an Optional Feature * Rename to foundation vite * Add ViteBundlerTest * Add missing end of file * Update tests * remove unnecessary end() call Co-authored-by: Samuel Štancl * rewrite ViteBundlerTest to phpunit syntax * skip vite test in Laravel < 9 * convert ViteBundler to PHP 7 syntax * remove import of nonexistent class in older Laravel versions * remove import of Foundation\Vite in tests * try to exclude Vite.php from coverage report * remove typehint * update channel name * Cache crash fix (#1048) * Don't prevent accessing missing Tenant attributes. (#1045) * [3.x] L10 compatibility (#1065) * Bump dependencies for Laravel 10 * Update GitHub Actions for Laravel 10 * ci: do not test L10 using PHP 7.3 * drop < L9 support * use `dispatch_sync` instead of `dispatch_now` * migrate phpunit configuration * Update ci.yml * drop laravel < 9 support * misc L10 fixes, new docker image * specify odbc version * wip * properly list php versions as strings * minor changes * Add `getValue($queryGrammar)` to raw query * Clean up `isVersion8` code * rewrite hasFailed assertion * phpunit schema update * Upgrade `doctrine/dbal` --------- Co-authored-by: Samuel Štancl Co-authored-by: Samuel Štancl Co-authored-by: lukinovec * Update ci.yml * Fix code style (php-cs-fixer) * Update dependencies * Change invade version * Delete ViteBundlerTest * Fix PHPStan error * Delete PHPStan error ignore * Fix CONTRIBUTING.md * Delete ViteBundler remains * Bring back ViteBundler * Convert ViteBundlerTest to Pest * Update ci.yml --------- Co-authored-by: Samuel Štancl Co-authored-by: Bram Wubs Co-authored-by: Bram Wubs Co-authored-by: Samuel Štancl Co-authored-by: George Bishop Co-authored-by: Anbuselvan Rocky <15264938+anburocky3@users.noreply.github.com> Co-authored-by: Wilsen Hernández <13445515+wilsenhc@users.noreply.github.com> Co-authored-by: Joel Stein Co-authored-by: Guilherme Saade Co-authored-by: PHP CS Fixer --- .github/workflows/ci.yml | 8 +++-- assets/config.php | 3 +- composer.json | 17 ++++++----- phpstan.neon | 4 --- src/Database/Models/ImpersonationToken.php | 5 ++-- src/Database/Models/Tenant.php | 2 ++ ...rmissionControlledMySQLDatabaseManager.php | 3 +- src/Features/ViteBundler.php | 26 +++++++++++++++++ src/TenancyServiceProvider.php | 1 + src/Vite.php | 22 ++++++++++++++ tests/EventListenerTest.php | 7 +++-- tests/Features/ViteBundlerTest.php | 29 +++++++++++++++++++ tests/TenantDatabaseManagerTest.php | 4 +-- 13 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 src/Features/ViteBundler.php create mode 100644 src/Vite.php create mode 100644 tests/Features/ViteBundlerTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314d6e4c..8ecb863b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,11 @@ jobs: strategy: matrix: - laravel: ['^9.0'] + include: + - laravel: 9 + php: "8.0" + - laravel: 10 + php: "8.1" steps: - name: Checkout @@ -23,7 +27,7 @@ jobs: - name: Install Composer dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer require "laravel/framework:^${{ matrix.laravel }}.0" --no-interaction --no-update composer update --prefer-dist --no-interaction - name: Run tests run: ./vendor/bin/pest diff --git a/assets/config.php b/assets/config.php index bbfa9974..7fc6c928 100644 --- a/assets/config.php +++ b/assets/config.php @@ -258,7 +258,7 @@ return [ ], /** - * Redis tenancy config. Used by RedisTenancyBoostrapper. + * Redis tenancy config. Used by RedisTenancyBootstrapper. * * Note: You need phpredis to use Redis tenancy. * @@ -286,6 +286,7 @@ return [ // Stancl\Tenancy\Features\TelescopeTags::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 + // Stancl\Tenancy\Features\ViteBundler::class, ], /** diff --git a/composer.json b/composer.json index bb11040e..b5734c1b 100644 --- a/composer.json +++ b/composer.json @@ -17,18 +17,19 @@ "require": { "php": "^8.2", "ext-json": "*", - "illuminate/support": "^9.38", + "illuminate/support": "^9.38|^10.0", + "facade/ignition-contracts": "^1.0.2", "spatie/ignition": "^1.4", - "ramsey/uuid": "^4.0", - "stancl/jobpipeline": "^1.0", - "stancl/virtualcolumn": "^1.3", + "ramsey/uuid": "^4.7.3", + "stancl/jobpipeline": "^1.6.2", + "stancl/virtualcolumn": "^1.3.1", "spatie/invade": "^1.1" }, "require-dev": { - "laravel/framework": "^9.38", - "orchestra/testbench": "^7.0", - "league/flysystem-aws-s3-v3": "^3.0", - "doctrine/dbal": "^2.10", + "laravel/framework": "^9.38|^10.0", + "orchestra/testbench": "^7.0|^8.0", + "league/flysystem-aws-s3-v3": "^3.12.2", + "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", "pestphp/pest": "^1.21", "nunomaduro/larastan": "^2.4", diff --git a/phpstan.neon b/phpstan.neon index 91e9f3af..19cda805 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -40,10 +40,6 @@ parameters: message: '#Illuminate\\Routing\\UrlGenerator#' paths: - src/Bootstrappers/FilesystemTenancyBootstrapper.php - - - message: '#select\(\) expects string, Illuminate\\Database\\Query\\Expression given#' - paths: - - src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php - message: '#Trying to invoke Closure\|null but it might not be a callable#' paths: diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php index 05d17ad4..3d7b595b 100644 --- a/src/Database/Models/ImpersonationToken.php +++ b/src/Database/Models/ImpersonationToken.php @@ -33,9 +33,8 @@ class ImpersonationToken extends Model public $incrementing = false; protected $table = 'tenant_user_impersonation_tokens'; - - protected $dates = [ - 'created_at', + protected $casts = [ + 'created_at' => 'datetime', ]; public static function booted(): void diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 37c2af2d..c3574942 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -32,6 +32,8 @@ class Tenant extends Model implements Contracts\Tenant Concerns\InitializationHelpers, Concerns\InvalidatesResolverCache; + protected static $modelsShouldPreventAccessingMissingAttributes = false; + protected $table = 'tenants'; protected $primaryKey = 'id'; protected $guarded = []; diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index f7e7440e..308d8786 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -41,7 +41,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl protected function isVersion8(): bool { - $version = $this->database()->select($this->database()->raw('select version()'))[0]->{'version()'}; + $versionSelect = (string) $this->database()->raw('select version()')->getValue($this->database()->getQueryGrammar()); + $version = $this->database()->select($versionSelect)[0]->{'version()'}; return version_compare($version, '8.0.0') >= 0; } diff --git a/src/Features/ViteBundler.php b/src/Features/ViteBundler.php new file mode 100644 index 00000000..e3fee2fa --- /dev/null +++ b/src/Features/ViteBundler.php @@ -0,0 +1,26 @@ +app = $app; + } + + public function bootstrap(Tenancy $tenancy): void + { + $this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class); + } +} diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 23fb6473..bde37055 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -62,6 +62,7 @@ class TenancyServiceProvider extends ServiceProvider $this->app->singleton(Commands\Rollback::class, function ($app) { return new Commands\Rollback($app['migrator']); }); + $this->app->singleton(Commands\Seed::class, function ($app) { return new Commands\Seed($app['db']); }); diff --git a/src/Vite.php b/src/Vite.php new file mode 100644 index 00000000..ca47fcc3 --- /dev/null +++ b/src/Vite.php @@ -0,0 +1,22 @@ +assertFalse($tenant->database()->manager()->databaseExists( $tenant->database()->getName() @@ -171,12 +171,13 @@ test('database is not migrated if creation is disabled', function () { })->toListener() ); - Tenant::create([ + $tenant = Tenant::create([ 'tenancy_create_database' => false, 'tenancy_db_name' => 'already_created', ]); - expect(pest()->hasFailed())->toBeFalse(); + // assert test didn't fail + $this->assertTrue($tenant->exists()); }); class FooListener extends QueueableListener diff --git a/tests/Features/ViteBundlerTest.php b/tests/Features/ViteBundlerTest.php new file mode 100644 index 00000000..0d4c9069 --- /dev/null +++ b/tests/Features/ViteBundlerTest.php @@ -0,0 +1,29 @@ +toBeInstanceOf(Vite::class); + expect($vite)->not()->toBeInstanceOf(StanclVite::class); + + config([ + 'tenancy.features' => [ViteBundler::class], + ]); + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + app()->forgetInstance(Vite::class); + + $vite = app(Vite::class); + + expect($vite)->toBeInstanceOf(StanclVite::class); +}); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 5d9a15d6..c776d7a1 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -302,7 +302,7 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM $mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';"); $mysql2DB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;"); $mysql2DB->statement("FLUSH PRIVILEGES;"); - + DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time config(['database.connections.mysql2.username' => $username]); @@ -347,7 +347,7 @@ test('tenant database can be created by using the username and password from ten $mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';"); $mysqlDB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;"); $mysqlDB->statement("FLUSH PRIVILEGES;"); - + DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time // Remove `mysql` credentials to make sure we will be using the credentials from the tenant config From e531f96c4bfe5c450c54e9306d769078b02ae93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 21 Feb 2023 17:47:25 +0100 Subject: [PATCH 38/39] specify full laravel versions to support using dev-master in PRs --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ecb863b..0fdee61d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,10 @@ jobs: strategy: matrix: include: - - laravel: 9 + - laravel: "^9.0" php: "8.0" - - laravel: 10 - php: "8.1" + - laravel: "^10.0" + php: "8.2" steps: - name: Checkout @@ -27,7 +27,7 @@ jobs: - name: Install Composer dependencies run: | - composer require "laravel/framework:^${{ matrix.laravel }}.0" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update composer update --prefer-dist --no-interaction - name: Run tests run: ./vendor/bin/pest From 8b7862d8ad09241a53cd3d0cc3ff5c85a5cfdfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 21 Feb 2023 18:28:57 +0100 Subject: [PATCH 39/39] add --xdebug option to phpstan --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fdee61d..5d4d0cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,4 +119,4 @@ jobs: - name: Install composer dependencies run: composer install - name: Run phpstan - run: vendor/bin/phpstan analyse + run: vendor/bin/phpstan analyse --xdebug