<?php namespace MongoDB\Tests; use InvalidArgumentException; use MongoDB\BSON\ObjectId; use MongoDB\Client; use MongoDB\Driver\Command; use MongoDB\Driver\Exception\CommandException; use MongoDB\Driver\Manager; use MongoDB\Driver\Query; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; use MongoDB\Driver\ServerApi; use MongoDB\Driver\WriteConcern; use MongoDB\Operation\CreateCollection; use MongoDB\Operation\DatabaseCommand; use MongoDB\Operation\DropCollection; use MongoDB\Operation\ListCollections; use stdClass; use UnexpectedValueException; use function array_merge; use function call_user_func; use function count; use function current; use function explode; use function filter_var; use function getenv; use function implode; use function is_array; use function is_callable; use function is_object; use function is_string; use function key; use function ob_get_clean; use function ob_start; use function parse_url; use function phpinfo; use function preg_match; use function preg_quote; use function preg_replace; use function sprintf; use function version_compare; use const FILTER_VALIDATE_BOOLEAN; use const INFO_MODULES; abstract class FunctionalTestCase extends TestCase { /** @var Manager */ protected $manager; /** @var array */ private $configuredFailPoints = []; public function setUp(): void { parent::setUp(); $this->manager = static::createTestManager(); $this->configuredFailPoints = []; } public function tearDown(): void { $this->disableFailPoints(); parent::tearDown(); } public static function createTestClient(?string $uri = null, array $options = [], array $driverOptions = []): Client { return new Client( $uri ?? static::getUri(), static::appendAuthenticationOptions($options), static::appendServerApiOption($driverOptions) ); } public static function createTestManager(?string $uri = null, array $options = [], array $driverOptions = []): Manager { return new Manager( $uri ?? static::getUri(), static::appendAuthenticationOptions($options), static::appendServerApiOption($driverOptions) ); } public static function getUri($allowMultipleMongoses = false): string { $uri = parent::getUri(); if ($allowMultipleMongoses) { return $uri; } $urlParts = parse_url($uri); if ($urlParts === false) { return $uri; } // Only modify URIs using the mongodb scheme if ($urlParts['scheme'] !== 'mongodb') { return $uri; } $hosts = explode(',', $urlParts['host']); $numHosts = count($hosts); if ($numHosts === 1) { return $uri; } $manager = static::createTestManager($uri); if ($manager->selectServer(new ReadPreference(ReadPreference::RP_PRIMARY))->getType() !== Server::TYPE_MONGOS) { return $uri; } // Re-append port to last host if (isset($urlParts['port'])) { $hosts[$numHosts - 1] .= ':' . $urlParts['port']; } $parts = ['mongodb://']; if (isset($urlParts['user'], $urlParts['pass'])) { $parts += [ $urlParts['user'], ':', $urlParts['pass'], '@', ]; } $parts[] = $hosts[0]; if (isset($urlParts['path'])) { $parts[] = $urlParts['path']; } if (isset($urlParts['query'])) { $parts = array_merge($parts, [ '?', $urlParts['query'], ]); } return implode('', $parts); } protected function assertCollectionCount($namespace, $count): void { [$databaseName, $collectionName] = explode('.', $namespace, 2); $cursor = $this->manager->executeCommand($databaseName, new Command(['count' => $collectionName])); $cursor->setTypeMap(['root' => 'array', 'document' => 'array']); $document = current($cursor->toArray()); $this->assertArrayHasKey('n', $document); $this->assertEquals($count, $document['n']); } /** * Asserts that a collection with the given name does not exist on the * server. * * $databaseName defaults to TestCase::getDatabaseName() if unspecified. */ protected function assertCollectionDoesNotExist(string $collectionName, ?string $databaseName = null): void { if (! isset($databaseName)) { $databaseName = $this->getDatabaseName(); } $operation = new ListCollections($this->getDatabaseName()); $collections = $operation->execute($this->getPrimaryServer()); $foundCollection = null; foreach ($collections as $collection) { if ($collection->getName() === $collectionName) { $foundCollection = $collection; break; } } $this->assertNull($foundCollection, sprintf('Collection %s exists', $collectionName)); } /** * Asserts that a collection with the given name exists on the server. * * $databaseName defaults to TestCase::getDatabaseName() if unspecified. * An optional $callback may be provided, which should take a CollectionInfo * argument as its first and only parameter. If a CollectionInfo matching * the given name is found, it will be passed to the callback, which may * perform additional assertions. */ protected function assertCollectionExists(string $collectionName, ?string $databaseName = null, ?callable $callback = null): void { if (! isset($databaseName)) { $databaseName = $this->getDatabaseName(); } if ($callback !== null && ! is_callable($callback)) { throw new InvalidArgumentException('$callback is not a callable'); } $operation = new ListCollections($databaseName); $collections = $operation->execute($this->getPrimaryServer()); $foundCollection = null; foreach ($collections as $collection) { if ($collection->getName() === $collectionName) { $foundCollection = $collection; break; } } $this->assertNotNull($foundCollection, sprintf('Found %s collection in the database', $collectionName)); if ($callback !== null) { call_user_func($callback, $foundCollection); } } protected function assertCommandSucceeded($document): void { $document = is_object($document) ? (array) $document : $document; $this->assertArrayHasKey('ok', $document); $this->assertEquals(1, $document['ok']); } protected function assertSameObjectId($expectedObjectId, $actualObjectId): void { $this->assertInstanceOf(ObjectId::class, $expectedObjectId); $this->assertInstanceOf(ObjectId::class, $actualObjectId); $this->assertEquals((string) $expectedObjectId, (string) $actualObjectId); } /** * Configure a fail point for the test. * * The fail point will automatically be disabled during tearDown() to avoid * affecting a subsequent test. * * @param array|stdClass $command configureFailPoint command document * @throws InvalidArgumentException if $command is not a configureFailPoint command */ public function configureFailPoint($command, ?Server $server = null): void { if (! $this->isFailCommandSupported()) { $this->markTestSkipped('failCommand is only supported on mongod >= 4.0.0 and mongos >= 4.1.5.'); } if (! $this->isFailCommandEnabled()) { $this->markTestSkipped('The enableTestCommands parameter is not enabled.'); } if (is_array($command)) { $command = (object) $command; } if (! $command instanceof stdClass) { throw new InvalidArgumentException('$command is not an array or stdClass instance'); } if (key((array) $command) !== 'configureFailPoint') { throw new InvalidArgumentException('$command is not a configureFailPoint command'); } $failPointServer = $server ?: $this->getPrimaryServer(); $operation = new DatabaseCommand('admin', $command); $cursor = $operation->execute($failPointServer); $result = $cursor->toArray()[0]; $this->assertCommandSucceeded($result); // Record the fail point so it can be disabled during tearDown() $this->configuredFailPoints[] = [$command->configureFailPoint, $failPointServer]; } /** * Creates the test collection with the specified options. * * If the "writeConcern" option is not specified but is supported by the * server, a majority write concern will be used. This is helpful for tests * using transactions or secondary reads. * * @param array $options */ protected function createCollection(array $options = []): void { if (version_compare($this->getServerVersion(), '3.4.0', '>=')) { $options += ['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]; } $operation = new CreateCollection($this->getDatabaseName(), $this->getCollectionName(), $options); $operation->execute($this->getPrimaryServer()); } /** * Drops the test collection with the specified options. * * If the "writeConcern" option is not specified but is supported by the * server, a majority write concern will be used. This is helpful for tests * using transactions or secondary reads. * * @param array $options */ protected function dropCollection(array $options = []): void { if (version_compare($this->getServerVersion(), '3.4.0', '>=')) { $options += ['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]; } $operation = new DropCollection($this->getDatabaseName(), $this->getCollectionName(), $options); $operation->execute($this->getPrimaryServer()); } protected function getFeatureCompatibilityVersion(?ReadPreference $readPreference = null) { if ($this->isShardedCluster()) { return $this->getServerVersion($readPreference); } if (version_compare($this->getServerVersion(), '3.4.0', '<')) { return $this->getServerVersion($readPreference); } $cursor = $this->manager->executeCommand( 'admin', new Command(['getParameter' => 1, 'featureCompatibilityVersion' => 1]), $readPreference ?: new ReadPreference(ReadPreference::RP_PRIMARY) ); $cursor->setTypeMap(['root' => 'array', 'document' => 'array']); $document = current($cursor->toArray()); // MongoDB 3.6: featureCompatibilityVersion is an embedded document if (isset($document['featureCompatibilityVersion']['version']) && is_string($document['featureCompatibilityVersion']['version'])) { return $document['featureCompatibilityVersion']['version']; } // MongoDB 3.4: featureCompatibilityVersion is a string if (isset($document['featureCompatibilityVersion']) && is_string($document['featureCompatibilityVersion'])) { return $document['featureCompatibilityVersion']; } throw new UnexpectedValueException('Could not determine featureCompatibilityVersion'); } protected function getPrimaryServer() { return $this->manager->selectServer(new ReadPreference(ReadPreference::RP_PRIMARY)); } protected function getServerVersion(?ReadPreference $readPreference = null) { $buildInfo = $this->manager->executeCommand( $this->getDatabaseName(), new Command(['buildInfo' => 1]), $readPreference ?: new ReadPreference(ReadPreference::RP_PRIMARY) )->toArray()[0]; if (isset($buildInfo->version) && is_string($buildInfo->version)) { return preg_replace('#^(\d+\.\d+\.\d+).*$#', '\1', $buildInfo->version); } throw new UnexpectedValueException('Could not determine server version'); } protected function getServerStorageEngine(?ReadPreference $readPreference = null) { $cursor = $this->manager->executeCommand( $this->getDatabaseName(), new Command(['serverStatus' => 1]), $readPreference ?: new ReadPreference('primary') ); $result = current($cursor->toArray()); if (isset($result->storageEngine->name) && is_string($result->storageEngine->name)) { return $result->storageEngine->name; } throw new UnexpectedValueException('Could not determine server storage engine'); } protected function isLoadBalanced() { return $this->getPrimaryServer()->getType() == Server::TYPE_LOAD_BALANCER; } protected function isReplicaSet() { return $this->getPrimaryServer()->getType() == Server::TYPE_RS_PRIMARY; } protected function isMongos() { return $this->getPrimaryServer()->getType() == Server::TYPE_MONGOS; } /** * Return whether serverless (i.e. proxy as mongos) is being utilized. */ protected static function isServerless(): bool { $isServerless = getenv('MONGODB_IS_SERVERLESS'); return $isServerless !== false ? filter_var($isServerless, FILTER_VALIDATE_BOOLEAN) : false; } protected function isShardedCluster() { $type = $this->getPrimaryServer()->getType(); if ($type == Server::TYPE_MONGOS) { return true; } // Assume that load balancers are properly configured and front mongos if ($type == Server::TYPE_LOAD_BALANCER) { return true; } return false; } protected function isShardedClusterUsingReplicasets() { $cursor = $this->getPrimaryServer()->executeQuery( 'config.shards', new Query([], ['limit' => 1]) ); $cursor->setTypeMap(['root' => 'array', 'document' => 'array']); $document = current($cursor->toArray()); if (! $document) { return false; } /** * Use regular expression to distinguish between standalone or replicaset: * Without a replicaset: "host" : "localhost:4100" * With a replicaset: "host" : "dec6d8a7-9bc1-4c0e-960c-615f860b956f/localhost:4400,localhost:4401" */ return preg_match('@^.*/.*:\d+@', $document['host']); } protected function skipIfChangeStreamIsNotSupported(): void { switch ($this->getPrimaryServer()->getType()) { case Server::TYPE_MONGOS: case Server::TYPE_LOAD_BALANCER: if (version_compare($this->getServerVersion(), '3.6.0', '<')) { $this->markTestSkipped('$changeStream is only supported on MongoDB 3.6 or higher'); } if (! $this->isShardedClusterUsingReplicasets()) { $this->markTestSkipped('$changeStream is only supported with replicasets'); } break; case Server::TYPE_RS_PRIMARY: if (version_compare($this->getFeatureCompatibilityVersion(), '3.6', '<')) { $this->markTestSkipped('$changeStream is only supported on FCV 3.6 or higher'); } break; default: $this->markTestSkipped('$changeStream is not supported'); } } protected function skipIfCausalConsistencyIsNotSupported(): void { switch ($this->getPrimaryServer()->getType()) { case Server::TYPE_MONGOS: case Server::TYPE_LOAD_BALANCER: if (version_compare($this->getServerVersion(), '3.6.0', '<')) { $this->markTestSkipped('Causal Consistency is only supported on MongoDB 3.6 or higher'); } if (! $this->isShardedClusterUsingReplicasets()) { $this->markTestSkipped('Causal Consistency is only supported with replicasets'); } break; case Server::TYPE_RS_PRIMARY: if (version_compare($this->getFeatureCompatibilityVersion(), '3.6', '<')) { $this->markTestSkipped('Causal Consistency is only supported on FCV 3.6 or higher'); } if ($this->getServerStorageEngine() !== 'wiredTiger') { $this->markTestSkipped('Causal Consistency requires WiredTiger storage engine'); } break; default: $this->markTestSkipped('Causal Consistency is not supported'); } } protected function skipIfClientSideEncryptionIsNotSupported(): void { if (version_compare($this->getFeatureCompatibilityVersion(), '4.2', '<')) { $this->markTestSkipped('Client Side Encryption only supported on FCV 4.2 or higher'); } if ($this->getModuleInfo('libmongocrypt') === 'disabled') { $this->markTestSkipped('Client Side Encryption is not enabled in the MongoDB extension'); } } protected function skipIfGeoHaystackIndexIsNotSupported(): void { if (version_compare($this->getServerVersion(), '4.9', '>=')) { $this->markTestSkipped('GeoHaystack indexes cannot be created in version 4.9 and above'); } } protected function skipIfTransactionsAreNotSupported(): void { if ($this->getPrimaryServer()->getType() === Server::TYPE_STANDALONE) { $this->markTestSkipped('Transactions are not supported on standalone servers'); } if ($this->isShardedCluster()) { if (! $this->isShardedClusterUsingReplicasets()) { $this->markTestSkipped('Transactions are not supported on sharded clusters without replica sets'); } if (version_compare($this->getFeatureCompatibilityVersion(), '4.2', '<')) { $this->markTestSkipped('Transactions are only supported on FCV 4.2 or higher'); } return; } if (version_compare($this->getFeatureCompatibilityVersion(), '4.0', '<')) { $this->markTestSkipped('Transactions are only supported on FCV 4.0 or higher'); } if ($this->getServerStorageEngine() !== 'wiredTiger') { $this->markTestSkipped('Transactions require WiredTiger storage engine'); } } private static function appendAuthenticationOptions(array $options): array { if (isset($options['username']) || isset($options['password'])) { return $options; } $username = getenv('MONGODB_USERNAME') ?: null; $password = getenv('MONGODB_PASSWORD') ?: null; if ($username !== null) { $options['username'] = $username; } if ($password !== null) { $options['password'] = $password; } return $options; } private static function appendServerApiOption(array $driverOptions): array { if (getenv('API_VERSION') && ! isset($driverOptions['serverApi'])) { $driverOptions['serverApi'] = new ServerApi(getenv('API_VERSION')); } return $driverOptions; } /** * Disables any fail points that were configured earlier in the test. * * This tracks fail points set via configureFailPoint() and should be called * during tearDown(). */ private function disableFailPoints(): void { if (empty($this->configuredFailPoints)) { return; } foreach ($this->configuredFailPoints as [$failPoint, $server]) { $operation = new DatabaseCommand('admin', ['configureFailPoint' => $failPoint, 'mode' => 'off']); $operation->execute($server); } } private function getModuleInfo(string $row): ?string { ob_start(); phpinfo(INFO_MODULES); $info = ob_get_clean(); $pattern = sprintf('/^%s([\w ]+)$/m', preg_quote($row . ' => ')); if (preg_match($pattern, $info, $matches) !== 1) { return null; } return $matches[1]; } /** * Checks if the failCommand command is supported on this server version * * @return bool */ private function isFailCommandSupported(): bool { $minVersion = $this->isShardedCluster() ? '4.1.5' : '4.0.0'; return version_compare($this->getServerVersion(), $minVersion, '>='); } /** * Checks if the failCommand command is enabled by checking the enableTestCommands parameter * * @return bool */ private function isFailCommandEnabled(): bool { try { $cursor = $this->manager->executeCommand( 'admin', new Command(['getParameter' => 1, 'enableTestCommands' => 1]) ); $document = current($cursor->toArray()); } catch (CommandException $e) { return false; } return isset($document->enableTestCommands) && $document->enableTestCommands === true; } }