mirror of
https://github.com/archtechx/tenancy.git
synced 2026-02-05 13:34:04 +00:00
Merge branch 'master' into bugfix/batch-bootstrapper
This commit is contained in:
commit
d1a8b741d1
16 changed files with 268 additions and 31 deletions
42
src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php
Normal file
42
src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Commands;
|
||||
|
||||
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
|
||||
{
|
||||
|
|
@ -29,12 +31,27 @@ class Run extends Command
|
|||
*/
|
||||
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()}");
|
||||
|
||||
Artisan::call($this->argument('commandname'));
|
||||
$this->comment('Command output:');
|
||||
$this->info(Artisan::output());
|
||||
$this->getLaravel()
|
||||
->make(Kernel::class)
|
||||
->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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Database\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\Tenancy\Database\Concerns\CentralConnection;
|
||||
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
|
||||
|
||||
/**
|
||||
* @property string $token
|
||||
|
|
@ -38,9 +41,15 @@ class ImpersonationToken extends Model
|
|||
public static function booted(): void
|
||||
{
|
||||
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->token = $model->token ?? Str::random(128);
|
||||
$model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard');
|
||||
$model->auth_guard = $authGuard;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/Exceptions/StatefulGuardRequiredException.php
Normal file
15
src/Exceptions/StatefulGuardRequiredException.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -14,12 +14,16 @@ class CrossDomainRedirect implements Feature
|
|||
{
|
||||
RedirectResponse::macro('domain', function (string $domain) {
|
||||
/** @var RedirectResponse $this */
|
||||
|
||||
// Replace first occurrence of the hostname fragment with $domain
|
||||
$url = $this->getTargetUrl();
|
||||
|
||||
/**
|
||||
* The original hostname in the redirect response.
|
||||
*
|
||||
* @var string $hostname
|
||||
*/
|
||||
$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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ namespace Stancl\Tenancy\Middleware;
|
|||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
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\Resolvers\PathTenantResolver;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
|
@ -37,6 +40,11 @@ class InitializeTenancyByPath extends IdentificationMiddleware
|
|||
// We don't want to initialize tenancy if the tenant is
|
||||
// simply injected into some route controller action.
|
||||
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(
|
||||
$request,
|
||||
$next,
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ class Tenancy
|
|||
$tenants = is_string($tenants) ? [$tenants] : $tenants;
|
||||
|
||||
// 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -58,11 +58,15 @@ if (! function_exists('global_cache')) {
|
|||
if (! function_exists('tenant_route')) {
|
||||
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);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Tests\Etc\ExampleSeeder;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Stancl\Tenancy\Tests\Etc\User;
|
||||
|
||||
beforeEach(function () {
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
function runCommandWorks(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
namespace Stancl\Tenancy\Tests\Etc\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\Tenancy\Concerns\HasATenantsOption;
|
||||
use Stancl\Tenancy\Concerns\TenantAwareCommand;
|
||||
use Stancl\Tenancy\Tests\Etc\User;
|
||||
|
||||
class AddUserCommand extends Command
|
||||
{
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
namespace Stancl\Tenancy\Tests\Etc\Console;
|
||||
|
||||
use Orchestra\Testbench\Foundation\Console\Kernel;
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel
|
|||
{
|
||||
protected $commands = [
|
||||
ExampleCommand::class,
|
||||
ExampleQuestionCommand::class,
|
||||
AddUserCommand::class,
|
||||
];
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
namespace Stancl\Tenancy\Tests\Etc\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
46
tests/Etc/Console/ExampleQuestionCommand.php
Normal file
46
tests/Etc/Console/ExampleQuestionCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,11 @@ beforeEach(function () {
|
|||
], function () {
|
||||
Route::get('/foo/{a}/{b}', function ($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()
|
||||
->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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,25 +4,27 @@ declare(strict_types=1);
|
|||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterval;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Auth\TokenGuard;
|
||||
use Illuminate\Auth\SessionGuard;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
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\TenancyInitialized;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Features\UserImpersonation;
|
||||
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\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 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 () {
|
||||
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()
|
||||
{
|
||||
pest()->artisan('tenants:migrate')->assertExitCode(0);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
'--realpath' => 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' => [
|
||||
'driver' => 'sync',
|
||||
'central' => true,
|
||||
|
|
@ -150,7 +150,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
*/
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue