1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 17:24:03 +00:00

Identification middleware & tests

This commit is contained in:
Samuel Štancl 2020-05-10 05:47:27 +02:00
parent a17727b437
commit 8ea4940f34
18 changed files with 362 additions and 174 deletions

View file

@ -19,6 +19,11 @@ return [
// //
], ],
'central_domains' => [
'127.0.0.1',
'localhost',
],
'storage' => [ 'storage' => [
'data_column' => 'data', 'data_column' => 'data',
@ -232,6 +237,6 @@ return [
* Middleware pushed to the global middleware stack. * Middleware pushed to the global middleware stack.
*/ */
'global_middleware' => [ // todo get rid of this 'global_middleware' => [ // todo get rid of this
Stancl\Tenancy\Middleware\InitializeTenancy::class, // Stancl\Tenancy\Middleware\InitializeTenancy::class,
], ],
]; ];

13
src/Contracts/Domain.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace Stancl\Tenancy\Contracts;
/**
* @property-read Tenant $tenant
*
* @see \Stancl\Tenancy\Database\Models\Domain
*/
interface Domain
{
public function tenant();
}

View file

@ -8,7 +8,8 @@ use Stancl\Tenancy\Events\DomainCreated;
use Stancl\Tenancy\Events\DomainDeleted; use Stancl\Tenancy\Events\DomainDeleted;
use Stancl\Tenancy\Events\DomainSaved; use Stancl\Tenancy\Events\DomainSaved;
use Stancl\Tenancy\Events\DomainUpdated; use Stancl\Tenancy\Events\DomainUpdated;
use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
use Stancl\Tenancy\Contracts;
/** /**
* @property string $domain * @property string $domain
@ -16,7 +17,7 @@ use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException;
* *
* @property-read Tenant $tenant * @property-read Tenant $tenant
*/ */
class Domain extends Model class Domain extends Model implements Contracts\Domain
{ {
public $guarded = []; public $guarded = [];
public $casts = [ public $casts = [
@ -28,7 +29,7 @@ class Domain extends Model
$ensureDomainIsNotOccupied = function (Domain $self) { $ensureDomainIsNotOccupied = function (Domain $self) {
if ($domain = Domain::where('domain', $self->domain)->first()) { if ($domain = Domain::where('domain', $self->domain)->first()) {
if ($domain->getKey() !== $self->getKey()) { if ($domain->getKey() !== $self->getKey()) {
throw new DomainsOccupiedByOtherTenantException; throw new DomainOccupiedByOtherTenantException($self->domain);
} }
} }
}; };

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Exception;
class DomainOccupiedByOtherTenantException extends Exception
{
public function __construct($domain)
{
parent::__construct("The $domain domain is occupied by another tenant.");
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Exceptions;
use Stancl\Tenancy\Contracts\TenantCannotBeCreatedException;
class DomainsOccupiedByOtherTenantException extends TenantCannotBeCreatedException
{
public function reason(): string
{
return "One or more of the tenant's domains are already occupied by another tenant.";
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Stancl\Tenancy\Exceptions;
use Exception;
class NotASubdomainException extends Exception
{
public function __construct(string $hostname)
{
parent::__construct("Hostname $hostname does not include a subdomain.");
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Stancl\Tenancy\Middleware;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Contracts\TenantResolver;
use Stancl\Tenancy\Tenancy;
abstract class IdentificationMiddleware
{
/** @var callable */
public static $onFail;
/** @var Tenancy */
protected $tenancy;
/** @var TenantResolver */
protected $resolver;
public function initializeTenancy($request, $next, ...$resolverArguments)
{
try {
$this->tenancy->initialize(
$this->resolver->resolve(...$resolverArguments)
);
} catch (TenantCouldNotBeIdentifiedException $e) {
$onFail = static::$onFail ?? function ($e) {
throw $e;
};
return $onFail($e, $request, $next);
}
return $next($request);
}
}

View file

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedException;
class InitializeTenancy
{
/** @var callable */
protected $onFail;
public function __construct(callable $onFail = null)
{
$this->onFail = $onFail ?? function ($e) {
throw $e;
};
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if (tenancy()->initialized) {
return $next($request);
}
if (! in_array($request->getHost(), config('tenancy.exempt_domains', []), true)) {
try {
tenancy()->init($request->getHost());
} catch (TenantCouldNotBeIdentifiedException $e) {
return ($this->onFail)($e, $request, $next);
}
}
return $next($request);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Stancl\Tenancy\Tenancy;
class InitializeTenancyByDomain extends IdentificationMiddleware
{
/** @var callable|null */
public static $onFail;
/** @var Tenancy */
protected $tenancy;
/** @var DomainTenantResolver */
protected $resolver;
public function __construct(Tenancy $tenancy, DomainTenantResolver $resolver)
{
$this->tenancy = $tenancy;
$this->resolver = $resolver;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
return $this->initializeTenancy(
$request, $next, $request->getHost()
);
}
}

View file

@ -8,7 +8,7 @@ use Illuminate\Routing\Route;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
class InitializeTenancyByPath class InitializeTenancyByPath extends IdentificationMiddleware
{ {
/** @var Tenancy */ /** @var Tenancy */
protected $tenancy; protected $tenancy;
@ -27,14 +27,15 @@ class InitializeTenancyByPath
/** @var Route $route */ /** @var Route $route */
$route = $request->route(); $route = $request->route();
// todo test the behavior described by the comment
// Only initialize tenancy if tenant is the first parameter // Only initialize tenancy if tenant is the first parameter
// 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] === 'tenant') { if ($route->parameterNames()[0] === 'tenant') {
$this->tenancy->initialize( return $this->initializeTenancy(
$this->resolver->resolve($route) $request, $next, $route
); );
} } // todo else case should probably throw exception about malformed route? or do we just leave that as the developer's responsibility?
return $next($request); return $next($request);
} }

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Response;
use Stancl\Tenancy\Exceptions\NotASubdomainException;
class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
{
/** @var callable|null */
public static $onInvalidSubdomain;
/**
* The index of the subdomain fragment in the hostname
* split by `.`. 0 for first fragment, 1 if you prefix
* your subdomain fragments with `www`.
*
* @var int
*/
public static $subdomainIndex = 0;
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$subdomain = $this->makeSubdomain($request->getHost());
// If a non-string, like a Response instance was returned
// from makeSubdomain() - due to NotASubDomainException
// being thrown, we abort by returning the value now.
if (! is_string($subdomain)) {
return $subdomain;
}
return $this->initializeTenancy(
$request, $next, $subdomain
);
}
/** @return string|Response|mixed */
protected function makeSubdomain(string $hostname)
{
$parts = explode('.', $hostname);
// If we're on localhost or an IP address, then we're not visiting a subdomain.
if (in_array(count($parts), [1, 4])) {
$handle = static::$onInvalidSubdomain ?? function ($e) {
throw $e;
};
return $handle(new NotASubdomainException($hostname));
}
// todo should we verify that the subdomain belongs to one of our central domains?
// if yes, then write a test for it.
return $parts[static::$subdomainIndex];
}
}

View file

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
/**
* Prevent access from tenant domains to central routes and vice versa.
*/
class PreventAccessFromTenantDomains
{
/** @var callable */
protected $central404;
public function __construct(callable $central404 = null)
{
$this->central404 = $central404 ?? function () {
return 404;
};
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
// If the route is universal, always let the request pass.
if ($this->routeHasMiddleware($request->route(), 'universal')) {
return $next($request);
}
// If the domain is not in exempt domains, it's a tenant domain.
// Tenant domains can't have routes without tenancy middleware.
$isExemptDomain = in_array($request->getHost(), config('tenancy.exempt_domains'));
$isTenantDomain = ! $isExemptDomain;
$isTenantRoute = $this->routeHasMiddleware($request->route(), 'tenancy');
if ($isTenantDomain && ! $isTenantRoute) { // accessing web routes from tenant domains
return redirect(config('tenancy.home_url'));
}
if ($isExemptDomain && $isTenantRoute) { // accessing tenant routes on web domains
return ($this->central404)($request, $next);
}
return $next($request);
}
public static function routeHasMiddleware(Route $route, $middleware): bool
{
if (in_array($middleware, $route->middleware(), true)) {
return true;
}
// Loop one level deep and check if the route's middleware
// groups have a `tenancy` 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;
}
}

View file

@ -2,6 +2,7 @@
namespace Stancl\Tenancy\Resolvers; namespace Stancl\Tenancy\Resolvers;
use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Contracts\TenantResolver; use Stancl\Tenancy\Contracts\TenantResolver;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
@ -10,6 +11,7 @@ class DomainTenantResolver implements TenantResolver
{ {
public function resolve(...$args): Tenant public function resolve(...$args): Tenant
{ {
/** @var Domain $domain */
$domain = config('tenancy.domain_model')::where('domain', $args[0])->first(); $domain = config('tenancy.domain_model')::where('domain', $args[0])->first();
if ($domain) { if ($domain) {

View file

@ -3,7 +3,7 @@
namespace Stancl\Tenancy; namespace Stancl\Tenancy;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Database\Models\Tenant; use Stancl\Tenancy\Database\Models\Tenant; // todo contract
class Tenancy class Tenancy
{ {
@ -18,6 +18,11 @@ class Tenancy
public function initialize(Tenant $tenant): void public function initialize(Tenant $tenant): void
{ {
// todo the id is something that should be on the contract, with a method
if ($this->initialized && $this->tenant->id === $tenant->id) {
return;
}
$this->tenant = $tenant; $this->tenant = $tenant;
$this->initialized = true; $this->initialized = true;

View file

@ -2,10 +2,12 @@
namespace Stancl\Tenancy\Tests\v3; namespace Stancl\Tenancy\Tests\v3;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Database\Models\Concerns\HasDomains; use Stancl\Tenancy\Database\Models\Concerns\HasDomains;
use Stancl\Tenancy\Exceptions\DomainsOccupiedByOtherTenantException; use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Stancl\Tenancy\Tests\TestCase; use Stancl\Tenancy\Tests\TestCase;
@ -15,6 +17,14 @@ class DomainTest extends TestCase
{ {
parent::setUp(); parent::setUp();
Route::group([
'middleware' => InitializeTenancyByDomain::class,
], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) {
return "$a + $b";
});
});
config(['tenancy.tenant_model' => Tenant::class]); config(['tenancy.tenant_model' => Tenant::class]);
} }
@ -46,7 +56,7 @@ class DomainTest extends TestCase
$tenant2 = Tenant::create(); $tenant2 = Tenant::create();
$this->expectException(DomainsOccupiedByOtherTenantException::class); $this->expectException(DomainOccupiedByOtherTenantException::class);
$tenant2->domains()->create([ $tenant2->domains()->create([
'domain' => 'foo.localhost', 'domain' => 'foo.localhost',
]); ]);
@ -61,29 +71,40 @@ class DomainTest extends TestCase
} }
/** @test */ /** @test */
public function tenancy_is_initialized_prior_to_controller_constructors() public function tenant_can_be_identified_by_domain()
{ {
// todo $tenant = Tenant::create([
$this->assertTrue(app('tenancy_was_initialized_in_constructor')); 'id' => 'acme',
]);
$tenant->domains()->create([
'domain' => 'foo.localhost',
]);
$this->assertFalse(tenancy()->initialized);
$this
->get('http://foo.localhost/foo/abc/xyz')
->assertSee('abc + xyz');
$this->assertTrue(tenancy()->initialized); $this->assertTrue(tenancy()->initialized);
$this->assertSame('acme', tenant('id')); $this->assertSame('acme', tenant('id'));
} }
/** @test */
public function onfail_logic_can_be_customized()
{
InitializeTenancyByDomain::$onFail = function () {
return 'foo';
};
$this
->get('http://foo.localhost/foo/abc/xyz')
->assertSee('foo');
}
} }
class Tenant extends Models\Tenant class Tenant extends Models\Tenant
{ {
use HasDomains; use HasDomains;
} }
class TestController
{
public function __construct()
{
app()->instance('tenancy_was_initialized_in_constructor', tenancy()->initialized);
}
public function index()
{
return 'foo';
}
}

View file

@ -10,13 +10,6 @@ use Stancl\Tenancy\Tests\TestCase;
class PathIdentificationTest extends TestCase class PathIdentificationTest extends TestCase
{ {
public function getEnvironmentSetup($app)
{
parent::getEnvironmentSetUp($app);
config(['tenancy.global_middleware' => []]);
}
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -28,8 +21,6 @@ class PathIdentificationTest extends TestCase
Route::get('/foo/{a}/{b}', function ($a, $b) { Route::get('/foo/{a}/{b}', function ($a, $b) {
return "$a + $b"; return "$a + $b";
}); });
Route::get('/bar', [TestController::class, 'index']);
}); });
} }
@ -42,8 +33,7 @@ class PathIdentificationTest extends TestCase
$this->assertFalse(tenancy()->initialized); $this->assertFalse(tenancy()->initialized);
$this $this->get('/acme/foo/abc/xyz');
->get('/acme/foo/abc/xyz');
$this->assertTrue(tenancy()->initialized); $this->assertTrue(tenancy()->initialized);
$this->assertSame('acme', tenant('id')); $this->assertSame('acme', tenant('id'));
@ -77,4 +67,16 @@ class PathIdentificationTest extends TestCase
$this->assertFalse(tenancy()->initialized); $this->assertFalse(tenancy()->initialized);
} }
/** @test */
public function onfail_logic_can_be_customized()
{
InitializeTenancyByPath::$onFail = function () {
return 'foo';
};
$this
->get('/acme/foo/abc/xyz')
->assertSee('foo');
}
} }

101
tests/v3/SubdomainTest.php Normal file
View file

@ -0,0 +1,101 @@
<?php
namespace Stancl\Tenancy\Tests\v3;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Database\Models\Concerns\HasDomains;
use Stancl\Tenancy\Exceptions\NotASubdomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Tests\TestCase;
class SubdomainTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
Route::group([
'middleware' => InitializeTenancyBySubdomain::class,
], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) {
return "$a + $b";
});
});
config(['tenancy.tenant_model' => Tenant::class]);
}
/** @test */
public function tenant_can_be_identified_by_subdomain()
{
$tenant = Tenant::create([
'id' => 'acme',
]);
$tenant->domains()->create([
'domain' => 'foo',
]);
$this->assertFalse(tenancy()->initialized);
$this
->get('http://foo.localhost/foo/abc/xyz')
->assertSee('abc + xyz');
$this->assertTrue(tenancy()->initialized);
$this->assertSame('acme', tenant('id'));
}
/** @test */
public function onfail_logic_can_be_customized()
{
InitializeTenancyBySubdomain::$onFail = function () {
return 'foo';
};
$this
->get('http://foo.localhost/foo/abc/xyz')
->assertSee('foo');
}
/** @test */
public function localhost_is_not_a_valid_subdomain()
{
$this->expectException(NotASubdomainException::class);
$this
->withoutExceptionHandling()
->get('http://localhost/foo/abc/xyz');
}
/** @test */
public function ip_address_is_not_a_valid_subdomain()
{
$this->expectException(NotASubdomainException::class);
$this
->withoutExceptionHandling()
->get('http://127.0.0.1/foo/abc/xyz');
}
/** @test */
public function oninvalidsubdomain_logic_can_be_customized()
{
// in this case, we need to return a response instance
// since a string would be treated as the subdomain
InitializeTenancyBySubdomain::$onInvalidSubdomain = function () {
return response('foo custom invalid subdomain handler');
};
$this
->withoutExceptionHandling()
->get('http://127.0.0.1/foo/abc/xyz')
->assertSee('foo custom invalid subdomain handler');
}
}
class Tenant extends Models\Tenant
{
use HasDomains;
}