Une application SaaS éducative nécessite une séparation stricte des données entre établissements scolaires. L'architecture multi-tenant par sous-domaine (ecole-alpha.classeo.local) permet cette isolation tout en utilisant une base de code unique. Le choix d'une résolution basée sur les sous-domaines plutôt que sur des headers ou tokens facilite le routage au niveau infrastructure (reverse proxy) et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
159 lines
5.4 KiB
PHP
159 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Shared\Infrastructure\Security;
|
|
|
|
use App\Shared\Infrastructure\Security\TenantAwareInterface;
|
|
use App\Shared\Infrastructure\Security\TenantVoter;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use stdClass;
|
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
|
|
|
#[CoversClass(TenantVoter::class)]
|
|
final class TenantVoterTest extends TestCase
|
|
{
|
|
private TenantContext $tenantContext;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->tenantContext = new TenantContext();
|
|
}
|
|
|
|
#[Test]
|
|
public function itAbstainsForNonTenantAwareSubjects(): void
|
|
{
|
|
$voter = new TenantVoter($this->tenantContext);
|
|
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$subject = new stdClass();
|
|
|
|
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
|
|
|
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itAbstainsForNonTenantAccessAttributes(): void
|
|
{
|
|
$tenantIdString = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
$this->setCurrentTenant($tenantIdString, 'ecole-alpha');
|
|
|
|
$voter = new TenantVoter($this->tenantContext);
|
|
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
$subject = $this->createTenantAwareSubject($tenantIdString);
|
|
|
|
// Voter should abstain for other attributes to not bypass other voters
|
|
foreach (['VIEW', 'EDIT', 'DELETE', 'ROLE_ADMIN'] as $attribute) {
|
|
$result = $voter->vote($token, $subject, [$attribute]);
|
|
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $result, "Should abstain for: {$attribute}");
|
|
}
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessWhenUserNotAuthenticated(): void
|
|
{
|
|
$this->setCurrentTenant('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'ecole-alpha');
|
|
|
|
$voter = new TenantVoter($this->tenantContext);
|
|
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn(null);
|
|
|
|
$subject = $this->createTenantAwareSubject('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
|
|
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
|
|
|
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsAccessWhenSubjectBelongsToCurrentTenant(): void
|
|
{
|
|
$tenantIdString = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
$this->setCurrentTenant($tenantIdString, 'ecole-alpha');
|
|
|
|
$voter = new TenantVoter($this->tenantContext);
|
|
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
$subject = $this->createTenantAwareSubject($tenantIdString);
|
|
|
|
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
|
|
|
self::assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessWhenSubjectBelongsToDifferentTenant(): void
|
|
{
|
|
// Current tenant is alpha
|
|
$this->setCurrentTenant('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'ecole-alpha');
|
|
|
|
$voter = new TenantVoter($this->tenantContext);
|
|
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
// Subject belongs to beta tenant
|
|
$subject = $this->createTenantAwareSubject('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
|
|
|
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
|
|
|
// Should be DENIED (will be converted to 404 by access denied handler)
|
|
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessWhenNoTenantContextSet(): void
|
|
{
|
|
// Don't set any tenant context
|
|
$voter = new TenantVoter($this->tenantContext);
|
|
|
|
$user = $this->createMock(UserInterface::class);
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
$subject = $this->createTenantAwareSubject('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
|
|
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
|
|
|
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
private function setCurrentTenant(string $tenantIdString, string $subdomain): void
|
|
{
|
|
$tenantId = TenantId::fromString($tenantIdString);
|
|
$config = new TenantConfig(
|
|
tenantId: $tenantId,
|
|
subdomain: $subdomain,
|
|
databaseUrl: "postgresql://user:pass@localhost:5432/classeo_{$subdomain}",
|
|
);
|
|
$this->tenantContext->setCurrentTenant($config);
|
|
}
|
|
|
|
private function createTenantAwareSubject(string $tenantIdString): TenantAwareInterface
|
|
{
|
|
$tenantId = TenantId::fromString($tenantIdString);
|
|
|
|
$subject = $this->createMock(TenantAwareInterface::class);
|
|
$subject->method('getTenantId')->willReturn($tenantId);
|
|
|
|
return $subject;
|
|
}
|
|
}
|