1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 16:54:05 +00:00

Merge branch 'master' into bugfix/batch-bootstrapper

This commit is contained in:
Abrar Ahmad 2022-09-05 11:40:24 +05:00 committed by GitHub
commit d1a8b741d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 268 additions and 31 deletions

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers\Integrations;
use Illuminate\Contracts\Config\Repository;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class ScoutTenancyBootstrapper implements TenancyBootstrapper
{
/** @var Repository */
protected $config;
/** @var string */
protected $originalScoutPrefix;
public function __construct(Repository $config)
{
$this->config = $config;
}
public function bootstrap(Tenant $tenant)
{
if (! isset($this->originalScoutPrefix)) {
$this->originalScoutPrefix = $this->config->get('scout.prefix');
}
$this->config->set('scout.prefix', $this->getTenantPrefix($tenant));
}
public function revert()
{
$this->config->set('scout.prefix', $this->originalScoutPrefix);
}
protected function getTenantPrefix(Tenant $tenant): string
{
return (string) $tenant->getTenantKey();
}
}

View file

@ -5,7 +5,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan; use Illuminate\Contracts\Console\Kernel;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
class Run extends Command class Run extends Command
{ {
@ -29,12 +31,27 @@ class Run extends Command
*/ */
public function handle() public function handle()
{ {
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { $argvInput = $this->ArgvInput();
tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($argvInput) {
$this->line("Tenant: {$tenant->getTenantKey()}"); $this->line("Tenant: {$tenant->getTenantKey()}");
Artisan::call($this->argument('commandname')); $this->getLaravel()
$this->comment('Command output:'); ->make(Kernel::class)
$this->info(Artisan::output()); ->handle($argvInput, new ConsoleOutput);
}); });
} }
/**
* Get command as ArgvInput instance.
*/
protected function ArgvInput(): ArgvInput
{
// Convert string command to array
$subCommand = explode(' ', $this->argument('commandname'));
// Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it
array_unshift($subCommand, 'artisan');
return new ArgvInput($subCommand);
}
} }

View file

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models; namespace Stancl\Tenancy\Database\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\CentralConnection; use Stancl\Tenancy\Database\Concerns\CentralConnection;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
/** /**
* @property string $token * @property string $token
@ -38,9 +41,15 @@ class ImpersonationToken extends Model
public static function booted(): void public static function booted(): void
{ {
static::creating(function ($model) { static::creating(function ($model) {
$authGuard = $model->auth_guard ?? config('auth.defaults.guard');
if (! Auth::guard($authGuard) instanceof StatefulGuard) {
throw new StatefulGuardRequiredException($authGuard);
}
$model->created_at = $model->created_at ?? $model->freshTimestamp(); $model->created_at = $model->created_at ?? $model->freshTimestamp();
$model->token = $model->token ?? Str::random(128); $model->token = $model->token ?? Str::random(128);
$model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard'); $model->auth_guard = $authGuard;
}); });
} }
} }

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Exception;
class StatefulGuardRequiredException extends Exception
{
public function __construct(string $guardName)
{
parent::__construct("Cannot use a non-stateful guard ('$guardName'). A guard implementing the Illuminate\\Contracts\\Auth\\StatefulGuard interface is required.");
}
}

View file

@ -14,12 +14,16 @@ class CrossDomainRedirect implements Feature
{ {
RedirectResponse::macro('domain', function (string $domain) { RedirectResponse::macro('domain', function (string $domain) {
/** @var RedirectResponse $this */ /** @var RedirectResponse $this */
// Replace first occurrence of the hostname fragment with $domain
$url = $this->getTargetUrl(); $url = $this->getTargetUrl();
/**
* The original hostname in the redirect response.
*
* @var string $hostname
*/
$hostname = parse_url($url, PHP_URL_HOST); $hostname = parse_url($url, PHP_URL_HOST);
$position = strpos($url, $hostname);
$this->setTargetUrl(substr_replace($url, $domain, $position, strlen($hostname))); $this->setTargetUrl((string) str($url)->replace($hostname, $domain));
return $this; return $this;
}); });

View file

@ -7,6 +7,9 @@ namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Stancl\Tenancy\Events\InitializingTenancy;
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
@ -37,6 +40,11 @@ class InitializeTenancyByPath extends IdentificationMiddleware
// We don't want to initialize tenancy if the tenant is // We don't want to initialize tenancy if the tenant is
// simply injected into some route controller action. // simply injected into some route controller action.
if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) {
// Set tenant as a default parameter for the URLs in the current request
Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) {
URL::defaults([PathTenantResolver::$tenantParameterName => $event->tenancy->tenant->getTenantKey()]);
});
return $this->initializeTenancy( return $this->initializeTenancy(
$request, $request,
$next, $next,

View file

@ -146,7 +146,7 @@ class Tenancy
$tenants = is_string($tenants) ? [$tenants] : $tenants; $tenants = is_string($tenants) ? [$tenants] : $tenants;
// Use all tenants if $tenants is falsey // Use all tenants if $tenants is falsey
$tenants = $tenants ?: $this->model()->cursor(); $tenants = $tenants ?: $this->model()->cursor(); // todo0 phpstan thinks this isn't needed, but tests fail without it
$originalTenant = $this->tenant; $originalTenant = $this->tenant;

View file

@ -58,11 +58,15 @@ if (! function_exists('global_cache')) {
if (! function_exists('tenant_route')) { if (! function_exists('tenant_route')) {
function tenant_route(string $domain, string $route, array $parameters = [], bool $absolute = true): string function tenant_route(string $domain, string $route, array $parameters = [], bool $absolute = true): string
{ {
// replace the first occurrence of the hostname fragment with $domain
$url = route($route, $parameters, $absolute); $url = route($route, $parameters, $absolute);
$hostname = parse_url($url, PHP_URL_HOST);
$position = strpos($url, $hostname);
return substr_replace($url, $domain, $position, strlen($hostname)); /**
* The original hostname in the generated route.
*
* @var string $hostname
*/
$hostname = parse_url($url, PHP_URL_HOST);
return (string) str($url)->replace($hostname, $domain);
} }
} }

View file

@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Tests\Etc\User;
beforeEach(function () { beforeEach(function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
@ -179,6 +180,29 @@ test('run command with array of tenants works', function () {
->expectsOutput('Tenant: ' . $tenantId2); ->expectsOutput('Tenant: ' . $tenantId2);
}); });
test('run command works when sub command asks questions and accepts arguments', function () {
$tenant = Tenant::create();
$id = $tenant->getTenantKey();
Artisan::call('tenants:migrate');
pest()->artisan("tenants:run --tenants=$id 'user:addwithname Abrar' ")
->expectsQuestion('What is your email?', 'email@localhost')
->expectsOutput("Tenant: $id")
->expectsOutput("User created: Abrar(email@localhost)");
// Assert we are in central context
expect(tenancy()->initialized)->toBeFalse();
// Assert user was created in tenant context
tenancy()->initialize($tenant);
$user = User::first();
// Assert user is same as provided using the command
expect($user->name)->toBe('Abrar');
expect($user->email)->toBe('email@localhost');
});
// todo@tests // todo@tests
function runCommandWorks(): void function runCommandWorks(): void
{ {

View file

@ -2,12 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\HasATenantsOption;
use Stancl\Tenancy\Concerns\TenantAwareCommand; use Stancl\Tenancy\Concerns\TenantAwareCommand;
use Stancl\Tenancy\Tests\Etc\User;
class AddUserCommand extends Command class AddUserCommand extends Command
{ {

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc\Console;
use Orchestra\Testbench\Foundation\Console\Kernel; use Orchestra\Testbench\Foundation\Console\Kernel;
@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel
{ {
protected $commands = [ protected $commands = [
ExampleCommand::class, ExampleCommand::class,
ExampleQuestionCommand::class,
AddUserCommand::class, AddUserCommand::class,
]; ];
} }

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View file

@ -0,0 +1,46 @@
<?php
namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Stancl\Tenancy\Tests\Etc\User;
class ExampleQuestionCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:addwithname {name}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$email = $this->ask('What is your email?');
User::create([
'name' => $this->argument('name'),
'email' => $email,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
]);
$this->line("User created: ". $this->argument('name') . "($email)");
return 0;
}
}

View file

@ -18,7 +18,11 @@ beforeEach(function () {
], function () { ], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) { Route::get('/foo/{a}/{b}', function ($a, $b) {
return "$a + $b"; return "$a + $b";
}); })->name('foo');
Route::get('/baz/{a}/{b}', function ($a, $b) {
return "$a - $b";
})->name('baz');
}); });
}); });
@ -123,3 +127,23 @@ test('tenant parameter name can be customized', function () {
->withoutExceptionHandling() ->withoutExceptionHandling()
->get('/acme/foo/abc/xyz'); ->get('/acme/foo/abc/xyz');
}); });
test('tenant parameter is set for all routes as the default parameter once the tenancy initialized', function () {
Tenant::create([
'id' => 'acme',
]);
expect(tenancy()->initialized)->toBeFalse();
// make a request that will initialize tenancy
pest()->get(route('foo', ['tenant' => 'acme', 'a' => 1, 'b' => 2]));
expect(tenancy()->initialized)->toBeTrue();
expect(tenant('id'))->toBe('acme');
// assert that the route WITHOUT the tenant parameter matches the route WITH the tenant parameter
expect(route('baz', ['a' => 1, 'b' => 2]))->toBe(route('baz', ['tenant' => 'acme', 'a' => 1, 'b' => 2]));
expect(route('baz', ['a' => 1, 'b' => 2]))->toBe('http://localhost/acme/baz/1/2'); // assert the full route string
pest()->get(route('baz', ['a' => 1, 'b' => 2]))->assertOk(); // Assert route don't need tenant parameter
});

View file

@ -4,25 +4,27 @@ declare(strict_types=1);
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Illuminate\Support\Str;
use Illuminate\Auth\TokenGuard;
use Illuminate\Auth\SessionGuard; use Illuminate\Auth\SessionGuard;
use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Models\ImpersonationToken;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Features\UserImpersonation;
use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Features\UserImpersonation;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Foundation\Auth\User as Authenticable; use Illuminate\Foundation\Auth\User as Authenticable;
use Stancl\Tenancy\Database\Models\ImpersonationToken;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
beforeEach(function () { beforeEach(function () {
pest()->artisan('migrate', [ pest()->artisan('migrate', [
@ -223,6 +225,46 @@ test('impersonation works with multiple models and guards', function () {
}); });
}); });
test('impersonation tokens can be created only with stateful guards', function () {
config([
'auth.guards' => [
'nonstateful' => [
'driver' => 'nonstateful',
'provider' => 'provider',
],
'stateful' => [
'driver' => 'session',
'provider' => 'provider',
],
],
'auth.providers.provider' => [
'driver' => 'eloquent',
'model' => ImpersonationUser::class,
],
]);
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'Joe',
'email' => 'joe@local',
'password' => bcrypt('secret'),
]);
});
Auth::extend('nonstateful', fn($app, $name, array $config) => new TokenGuard(Auth::createUserProvider($config['provider']), request()));
expect(fn() => tenancy()->impersonate($tenant, $user->id, '/dashboard', 'nonstateful'))
->toThrow(StatefulGuardRequiredException::class);
Auth::extend('stateful', fn ($app, $name, array $config) => new SessionGuard($name, Auth::createUserProvider($config['provider']), session()));
expect(tenancy()->impersonate($tenant, $user->id, '/dashboard', 'stateful'))
->toBeInstanceOf(ImpersonationToken::class);
});
function migrateTenants() function migrateTenants()
{ {
pest()->artisan('tenants:migrate')->assertExitCode(0); pest()->artisan('tenants:migrate')->assertExitCode(0);

View file

@ -103,7 +103,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'--realpath' => true, '--realpath' => true,
'--force' => true, '--force' => true,
], ],
'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that
'queue.connections.central' => [ 'queue.connections.central' => [
'driver' => 'sync', 'driver' => 'sync',
'central' => true, 'central' => true,
@ -150,7 +150,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
*/ */
protected function resolveApplicationConsoleKernel($app) protected function resolveApplicationConsoleKernel($app)
{ {
$app->singleton('Illuminate\Contracts\Console\Kernel', Etc\ConsoleKernel::class); $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class);
} }
public function randomString(int $length = 10) public function randomString(int $length = 10)