Skip to content

Commit

Permalink
feat(resolvers): Add the cookie identity resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
ollieread committed Sep 9, 2024
1 parent dcf982c commit c1626a8
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 1 deletion.
6 changes: 5 additions & 1 deletion resources/config/multitenancy.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@
],

'header' => [
'driver' => 'header',
'driver' => 'header',
],

'cookie' => [
'driver' => 'cookie',
],

],
Expand Down
160 changes: 160 additions & 0 deletions src/Http/Resolvers/CookieIdentityResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);

namespace Sprout\Http\Resolvers;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Router;
use Illuminate\Routing\RouteRegistrar;
use Illuminate\Support\Facades\Cookie;
use Sprout\Contracts\IdentityResolverTerminates;
use Sprout\Contracts\Tenancy;
use Sprout\Http\Middleware\TenantRoutes;
use Sprout\Support\BaseIdentityResolver;

final class CookieIdentityResolver extends BaseIdentityResolver implements IdentityResolverTerminates
{
private string $cookie;

/**
* @var array<string, mixed>
*/
private array $options;

/**
* @param string $name
* @param string|null $cookie
* @param array<string, mixed> $options
*/
public function __construct(string $name, ?string $cookie = null, array $options = [])
{
parent::__construct($name);

$this->cookie = $cookie ?? '{Tenancy}-Identifier';
$this->options = $options;
}

public function getCookie(): string
{
return $this->cookie;
}

/**
* @param \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy
*
* @return string
*/
public function getRequestCookieName(Tenancy $tenancy): string
{
return str_replace(
['{tenancy}', '{resolver}', '{Tenancy}', '{Resolver}'],
[$tenancy->getName(), $this->getName(), ucfirst($tenancy->getName()), ucfirst($this->getName())],
$this->getCookie()
);
}

/**
* Get an identifier from the request
*
* Locates a tenant identifier within the provided request and returns it.
*
* @template TenantClass of \Sprout\Contracts\Tenant
*
* @param \Illuminate\Http\Request $request
* @param \Sprout\Contracts\Tenancy<TenantClass> $tenancy
*
* @return string|null
*/
public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string
{
/**
* This is unfortunately here because of the ludicrous return type
*
* @var string|null $cookie
*/
$cookie = $request->cookie($this->getRequestCookieName($tenancy));

return $cookie;
}

/**
* Create a route group for the resolver
*
* Creates and configures a route group with the necessary settings to
* support identity resolution.
*
* @template TenantClass of \Sprout\Contracts\Tenant
*
* @param \Illuminate\Routing\Router $router
* @param \Closure $groupRoutes
* @param \Sprout\Contracts\Tenancy<TenantClass> $tenancy
*
* @return \Illuminate\Routing\RouteRegistrar
*/
public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): RouteRegistrar
{
return $router->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()])
->group($groupRoutes);
}

/**
* @param \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy
* @param \Illuminate\Http\Response $response
*
* @return void
*/
public function terminate(Tenancy $tenancy, Response $response): void
{
if ($tenancy->check()) {
/**
* @var array{name:string, value:string} $details
*/
$details = $this->getCookieDetails(
[
'name' => $this->getRequestCookieName($tenancy),
'value' => $tenancy->identifier(),
]
);

$response->withCookie(Cookie::make(...$details));
}
}

/**
* @param array<string, mixed> $details
*
* @return array<string, mixed>
*
* @codeCoverageIgnore
*/
private function getCookieDetails(array $details): array
{
if (isset($this->options['minutes'])) {
$details['minutes'] = $this->options['minutes'];
}

if (isset($this->options['path'])) {
$details['path'] = $this->options['path'];
}

if (isset($this->options['domain'])) {
$details['domain'] = $this->options['domain'];
}

if (isset($this->options['secure'])) {
$details['secure'] = $this->options['secure'];
}

if (isset($this->options['httpOnly'])) {
$details['httpOnly'] = $this->options['httpOnly'];
}

if (isset($this->options['sameSite'])) {
$details['sameSite'] = $this->options['sameSite'];
}

return $details;
}
}
20 changes: 20 additions & 0 deletions src/Managers/IdentityResolverManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Sprout\Managers;

use InvalidArgumentException;
use Sprout\Http\Resolvers\CookieIdentityResolver;
use Sprout\Http\Resolvers\HeaderIdentityResolver;
use Sprout\Http\Resolvers\PathIdentityResolver;
use Sprout\Http\Resolvers\SubdomainIdentityResolver;
Expand Down Expand Up @@ -107,4 +108,23 @@ protected function createHeaderResolver(array $config, string $name): HeaderIden
$config['header'] ?? null
);
}

/**
* Create the cookie identity resolver
*
* @param array<string, mixed> $config
* @param string $name
*
* @phpstan-param array{cookie?: string|null, options?: array|null} $config
*
* @return \Sprout\Http\Resolvers\CookieIdentityResolver
*/
protected function createCookieResolver(array $config, string $name): CookieIdentityResolver

Check failure on line 122 in src/Managers/IdentityResolverManager.php

View workflow job for this annotation

GitHub Actions / Source Code

Method Sprout\Managers\IdentityResolverManager::createCookieResolver() has parameter $config with no value type specified in iterable type array.
{
return new CookieIdentityResolver(
$name,
$config['cookie'] ?? null,
$config['options'] ?? []
);
}
}
75 changes: 75 additions & 0 deletions tests/Resolvers/CookieResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);

namespace Sprout\Tests\Resolvers;

use Illuminate\Config\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Router;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Sprout\Attributes\CurrentTenant;
use Sprout\Contracts\Tenant;
use Workbench\App\Models\TenantModel;

#[Group('resolvers'), Group('cookies')]
class CookieResolverTest extends TestCase
{
use WithWorkbench, RefreshDatabase;

protected $enablesPackageDiscoveries = true;

protected function defineEnvironment($app): void
{
tap($app['config'], static function (Repository $config) {
$config->set('multitenancy.providers.tenants.model', TenantModel::class);
$config->set('multitenancy.defaults.resolver', 'cookie');
$config->set('multitenancy.resolvers.cookie', [
'driver' => 'cookie',
'cookie' => '{Tenancy}-Identifier',
]);
});
}

protected function defineRoutes($router)
{
$router->get('/', function () {
return 'no';
});

$router->tenanted(function (Router $router) {
$router->get('/cookie-route', function (#[CurrentTenant] Tenant $tenant) {
return $tenant->getTenantKey();
})->name('cookie.route');
}, 'cookie', 'tenants');
}

#[Test]
public function resolvesFromRoute(): void
{
$tenant = TenantModel::first();

$result = $this->withUnencryptedCookie('Tenants-Identifier', $tenant->getTenantIdentifier())->get(route('cookie.route'));

$result->assertOk();
$result->assertContent((string)$tenant->getTenantKey());
$result->cookie('Tenants-Identifier', $tenant->getTenantIdentifier());
}

#[Test]
public function throwsExceptionForInvalidTenant(): void
{
$result = $this->withCookie('Tenants-Identifier', 'i-am-not-real')->get(route('cookie.route'));
$result->assertInternalServerError();
}

#[Test]
public function throwsExceptionWithoutHeader(): void
{
$result = $this->get(route('cookie.route'));

$result->assertInternalServerError();
}
}

0 comments on commit c1626a8

Please sign in to comment.