tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); $this->tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901'); $this->configAlpha = new TenantConfig( tenantId: $this->tenantIdAlpha, subdomain: 'ecole-alpha', databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha', ); $this->configBeta = new TenantConfig( tenantId: $this->tenantIdBeta, subdomain: 'ecole-beta', databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_beta', ); $this->registry = new InMemoryTenantRegistry([$this->configAlpha, $this->configBeta]); $this->context = new TenantContext(); $this->resolver = new TenantResolver($this->registry, self::BASE_DOMAIN); $this->middleware = new TenantMiddleware($this->resolver, $this->context); $this->voter = new TenantVoter($this->context); } #[Test] public function userFromTenantACannotAccessResourceFromTenantB(): void { // User authenticates on tenant Alpha $this->context->setCurrentTenant($this->configAlpha); // Create a resource belonging to tenant Beta $resourceFromBeta = $this->createTenantAwareResource($this->tenantIdBeta); // Create authenticated user token $user = $this->createMock(UserInterface::class); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); // Vote should DENY access $result = $this->voter->vote($token, $resourceFromBeta, [TenantVoter::ATTRIBUTE]); self::assertSame(VoterInterface::ACCESS_DENIED, $result); } #[Test] public function userFromTenantACanAccessOwnResources(): void { // User authenticates on tenant Alpha $this->context->setCurrentTenant($this->configAlpha); // Create a resource belonging to tenant Alpha $resourceFromAlpha = $this->createTenantAwareResource($this->tenantIdAlpha); // Create authenticated user token $user = $this->createMock(UserInterface::class); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); // Vote should GRANT access $result = $this->voter->vote($token, $resourceFromAlpha, [TenantVoter::ATTRIBUTE]); self::assertSame(VoterInterface::ACCESS_GRANTED, $result); } #[Test] public function unknownSubdomainReturns404(): void { $request = Request::create('https://ecole-inexistant.classeo.local/api/dashboard'); $event = $this->createRequestEvent($request); $this->middleware->onKernelRequest($event); self::assertTrue($event->hasResponse()); self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode()); // Verify error message is generic (no information leakage) $content = json_decode((string) $event->getResponse()->getContent(), true); self::assertSame('Resource not found', $content['message']); self::assertArrayNotHasKey('subdomain', $content); self::assertArrayNotHasKey('tenant', $content); } #[Test] public function validSubdomainSetsContext(): void { $request = Request::create('https://ecole-alpha.classeo.local/api/dashboard'); $event = $this->createRequestEvent($request); $this->middleware->onKernelRequest($event); self::assertFalse($event->hasResponse()); // No error response self::assertTrue($this->context->hasTenant()); self::assertTrue($this->tenantIdAlpha->equals($this->context->getCurrentTenantId())); } #[Test] public function accessToResourceFromOtherTenantReturns404NotRevealingExistence(): void { // This test verifies the critical security requirement: // When denied access to a resource from another tenant, // the response MUST be 404 (not 403) to prevent enumeration attacks. // User is authenticated on tenant Alpha $this->context->setCurrentTenant($this->configAlpha); // Resource exists in tenant Beta $resourceFromBeta = $this->createTenantAwareResource($this->tenantIdBeta); // Create authenticated user token $user = $this->createMock(UserInterface::class); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); // Vote should DENY (which will be converted to 404 by TenantAccessDeniedHandler) $result = $this->voter->vote($token, $resourceFromBeta, [TenantVoter::ATTRIBUTE]); // The voter returns DENIED, not ABSTAIN // This ensures the access is actively denied rather than just not supported self::assertSame(VoterInterface::ACCESS_DENIED, $result); // The AccessDeniedHandler will convert this to 404 // (tested separately in TenantAccessDeniedHandler tests) } #[Test] public function tenantContextIsClearedOnRequestTermination(): void { // Set a tenant $this->context->setCurrentTenant($this->configAlpha); self::assertTrue($this->context->hasTenant()); // Terminate request $this->middleware->onKernelTerminate(); // Context should be cleared self::assertFalse($this->context->hasTenant()); } #[Test] public function subdomainMismatchWithAuthenticatedUserFromDifferentTenant(): void { // User tries to access ecole-beta.classeo.local $request = Request::create('https://ecole-beta.classeo.local/api/dashboard'); $event = $this->createRequestEvent($request); $this->middleware->onKernelRequest($event); // Context should be set to Beta (the subdomain in the request) self::assertTrue($this->context->hasTenant()); self::assertTrue($this->tenantIdBeta->equals($this->context->getCurrentTenantId())); // If a user's JWT was issued by Alpha but they're accessing Beta, // the authentication layer should reject the token (not tested here, // that's handled by JWT validation which checks tenant claims) } #[Test] public function reservedSubdomainsAreRejected(): void { $reservedSubdomains = ['www', 'api', 'admin', 'static', 'cdn', 'mail']; foreach ($reservedSubdomains as $subdomain) { $this->context->clear(); $request = Request::create("https://{$subdomain}.classeo.local/api/test"); $event = $this->createRequestEvent($request); $this->middleware->onKernelRequest($event); self::assertTrue( $event->hasResponse(), "Expected 404 response for reserved subdomain: {$subdomain}" ); self::assertSame( Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode(), "Expected 404 for reserved subdomain: {$subdomain}" ); } } #[Test] public function testWithTwoTenantsMinimum(): void { // This test ensures we always test with at least 2 tenants // as per the project's critical rules // Verify both tenants are set up self::assertTrue($this->registry->exists('ecole-alpha')); self::assertTrue($this->registry->exists('ecole-beta')); // Test Alpha isolation $this->context->setCurrentTenant($this->configAlpha); $resourceAlpha = $this->createTenantAwareResource($this->tenantIdAlpha); $resourceBeta = $this->createTenantAwareResource($this->tenantIdBeta); $user = $this->createMock(UserInterface::class); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); self::assertSame( VoterInterface::ACCESS_GRANTED, $this->voter->vote($token, $resourceAlpha, [TenantVoter::ATTRIBUTE]), 'Alpha user should access Alpha resource' ); self::assertSame( VoterInterface::ACCESS_DENIED, $this->voter->vote($token, $resourceBeta, [TenantVoter::ATTRIBUTE]), 'Alpha user should NOT access Beta resource' ); // Test Beta isolation $this->context->setCurrentTenant($this->configBeta); self::assertSame( VoterInterface::ACCESS_DENIED, $this->voter->vote($token, $resourceAlpha, [TenantVoter::ATTRIBUTE]), 'Beta user should NOT access Alpha resource' ); self::assertSame( VoterInterface::ACCESS_GRANTED, $this->voter->vote($token, $resourceBeta, [TenantVoter::ATTRIBUTE]), 'Beta user should access Beta resource' ); } private function createRequestEvent(Request $request): RequestEvent { $kernel = $this->createMock(HttpKernelInterface::class); return new RequestEvent( $kernel, $request, HttpKernelInterface::MAIN_REQUEST ); } private function createTenantAwareResource(TenantId $tenantId): TenantAwareInterface { $resource = $this->createMock(TenantAwareInterface::class); $resource->method('getTenantId')->willReturn($tenantId); return $resource; } }