diff --git a/tests/SpecTests/ClientSideEncryption/Prose22_RangeExplicitEncryptionTest.php b/tests/SpecTests/ClientSideEncryption/Prose22_RangeExplicitEncryptionTest.php new file mode 100644 index 000000000..f805df3c0 --- /dev/null +++ b/tests/SpecTests/ClientSideEncryption/Prose22_RangeExplicitEncryptionTest.php @@ -0,0 +1,448 @@ +isStandalone()) { + $this->markTestSkipped('Range explicit encryption tests require replica sets'); + } + + $this->skipIfServerVersion('<', '8.0.0', 'Range explicit encryption tests require MongoDB 8.0 or later'); + + $client = static::createTestClient(); + + $key1Document = $this->decodeJson(file_get_contents(__DIR__ . '/../client-side-encryption/etc/data/keys/key1-document.json')); + $this->key1Id = $key1Document->_id; + + // Drop the key vault collection and insert key1Document with a majority write concern + self::insertKeyVaultData($client, [$key1Document]); + + $this->clientEncryption = $client->createClientEncryption([ + 'keyVaultNamespace' => 'keyvault.datakeys', + 'kmsProviders' => ['local' => ['key' => new Binary(base64_decode(self::LOCAL_MASTERKEY))]], + ]); + + $autoEncryptionOpts = [ + 'keyVaultNamespace' => 'keyvault.datakeys', + 'kmsProviders' => ['local' => ['key' => new Binary(base64_decode(self::LOCAL_MASTERKEY))]], + 'bypassQueryAnalysis' => true, + ]; + + $this->encryptedClient = self::createTestClient(null, [], [ + 'autoEncryption' => $autoEncryptionOpts, + /* libmongocrypt caches results from listCollections. Use a new + * client in each test to ensure its encryptedFields is applied. */ + 'disableClientPersistence' => true, + ]); + } + + public function setUpWithTypeAndRangeOpts(string $type, array $rangeOpts): void + { + if ($type === 'DecimalNoPrecision' || $type === 'DecimalPrecision') { + $this->markTestSkipped('Bundled libmongocrypt does not support Decimal128 (PHPC-2207)'); + } + + /* Read the encryptedFields file directly into BSON to preserve typing + * for 64-bit integers. This means that DropEncryptedCollection and + * CreateEncryptedCollection will be unable to inspect the option for + * metadata collection names, but that's not necessary for the test. */ + $encryptedFields = Document::fromJSON(file_get_contents(__DIR__ . '/../client-side-encryption/etc/data/range-encryptedFields-' . $type . '.json')); + + $database = $this->encryptedClient->selectDatabase($this->getDatabaseName()); + $database->dropCollection('explicit_encryption', ['encryptedFields' => $encryptedFields]); + $database->createCollection('explicit_encryption', ['encryptedFields' => $encryptedFields]); + $this->collection = $database->selectCollection('explicit_encryption'); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $fieldName = 'encrypted' . $type; + + $this->collection->insertMany([ + ['_id' => 0, $fieldName => $this->clientEncryption->encrypt(self::cast($type, 0), $encryptOpts)], + ['_id' => 1, $fieldName => $this->clientEncryption->encrypt(self::cast($type, 6), $encryptOpts)], + ['_id' => 2, $fieldName => $this->clientEncryption->encrypt(self::cast($type, 30), $encryptOpts)], + ['_id' => 3, $fieldName => $this->clientEncryption->encrypt(self::cast($type, 200), $encryptOpts)], + ]); + } + + public function tearDown(): void + { + /* Since encryptedClient is created with disableClientPersistence=true, + * free any objects that may hold a reference to its mongoc_client_t */ + $this->collection = null; + $this->clientEncryption = null; + $this->encryptedClient = null; + } + + /** @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#test-setup-rangeopts */ + public static function provideTypeAndRangeOpts(): Generator + { + // TODO: skip DecimalNoPrecision test on mongos + yield 'DecimalNoPrecision' => [ + 'DecimalNoPrecision', + ['sparsity' => 1], + ]; + + yield 'DecimalPrecision' => [ + 'DecimalPrecision', + [ + 'min' => new Decimal128('0'), + 'max' => new Decimal128('200'), + 'sparsity' => 1, + 'precision' => 2, + ], + ]; + + yield 'DoubleNoPrecision' => [ + 'DoubleNoPrecision', + ['sparsity' => 1], + ]; + + yield 'DoublePrecision' => [ + 'DoublePrecision', + [ + 'min' => 0.0, + 'max' => 200.0, + 'sparsity' => 1, + 'precision' => 2, + ], + ]; + + yield 'Date' => [ + 'Date', + [ + 'min' => new UTCDateTime(0), + 'max' => new UTCDateTime(200), + 'sparsity' => 1, + ], + ]; + + yield 'Int' => [ + 'Int', + [ + 'min' => 0, + 'max' => 200, + 'sparsity' => 1, + ], + ]; + + yield 'Long' => [ + 'Long', + [ + 'min' => new Int64(0), + 'max' => new Int64(200), + 'sparsity' => 1, + ], + ]; + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-1-can-decrypt-a-payload + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase1_CanDecryptAPayload(string $type, array $rangeOpts): void + { + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $originalValue = self::cast($type, 6); + + $insertPayload = $this->clientEncryption->encrypt($originalValue, $encryptOpts); + $decryptedValue = $this->clientEncryption->decrypt($insertPayload); + + /* Decryption of a 64-bit integer will likely result in a scalar int, so + * cast it back to an Int64 before comparing to the original value. */ + if ($type === 'Long' && is_int($decryptedValue)) { + $decryptedValue = self::cast($type, $decryptedValue); + } + + /* Use separate assertions for type and equality as assertSame isn't + * suitable for comparing BSON objects and using assertEquals alone + * would disregard scalar type differences. */ + $this->assertSame(get_debug_type($originalValue), get_debug_type($decryptedValue)); + $this->assertEquals($originalValue, $decryptedValue); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-2-can-find-encrypted-range-and-return-the-maximum + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase2_CanFindEncryptedRangeAndReturnTheMaximum(string $type, array $rangeOpts): void + { + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $fieldName = 'encrypted' . $type; + + $expr = [ + '$and' => [ + [$fieldName => ['$gte' => self::cast($type, 6)]], + [$fieldName => ['$lte' => self::cast($type, 200)]], + ], + ]; + + $encryptedExpr = $this->clientEncryption->encryptExpression($expr, $encryptOpts); + $cursor = $this->collection->find($encryptedExpr, ['sort' => ['_id' => 1]]); + + $expectedDocuments = [ + ['_id' => 1, $fieldName => self::cast($type, 6)], + ['_id' => 2, $fieldName => self::cast($type, 30)], + ['_id' => 3, $fieldName => self::cast($type, 200)], + ]; + + $this->assertMultipleDocumentsMatch($expectedDocuments, $cursor); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-3-can-find-encrypted-range-and-return-the-minimum + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase3_CanFindEncryptedRangeAndReturnTheMinimum(string $type, array $rangeOpts): void + { + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $fieldName = 'encrypted' . $type; + + $expr = [ + '$and' => [ + [$fieldName => ['$gte' => self::cast($type, 0)]], + [$fieldName => ['$lte' => self::cast($type, 6)]], + ], + ]; + + $encryptedExpr = $this->clientEncryption->encryptExpression($expr, $encryptOpts); + $cursor = $this->collection->find($encryptedExpr, ['sort' => ['_id' => 1]]); + + $expectedDocuments = [ + ['_id' => 0, $fieldName => self::cast($type, 0)], + ['_id' => 1, $fieldName => self::cast($type, 6)], + ]; + + $this->assertMultipleDocumentsMatch($expectedDocuments, $cursor); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-4-can-find-encrypted-range-with-an-open-range-query + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase4_CanFindEncryptedRangeWithAnOpenRangeQuery(string $type, array $rangeOpts): void + { + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $fieldName = 'encrypted' . $type; + + $expr = ['$and' => [[$fieldName => ['$gt' => self::cast($type, 30)]]]]; + + $encryptedExpr = $this->clientEncryption->encryptExpression($expr, $encryptOpts); + $cursor = $this->collection->find($encryptedExpr, ['sort' => ['_id' => 1]]); + $expectedDocuments = [['_id' => 3, $fieldName => self::cast($type, 200)]]; + + $this->assertMultipleDocumentsMatch($expectedDocuments, $cursor); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-5-can-run-an-aggregation-expression-inside-expr + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase5_CanRunAnAggregationExpressionInsideExpr(string $type, array $rangeOpts): void + { + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $fieldName = 'encrypted' . $type; + $fieldPath = '$' . $fieldName; + + $expr = ['$and' => [['$lt' => [$fieldPath, self::cast($type, 30)]]]]; + + $encryptedExpr = $this->clientEncryption->encryptExpression($expr, $encryptOpts); + $cursor = $this->collection->find(['$expr' => $encryptedExpr], ['sort' => ['_id' => 1]]); + + $expectedDocuments = [ + ['_id' => 0, $fieldName => self::cast($type, 0)], + ['_id' => 1, $fieldName => self::cast($type, 6)], + ]; + + $this->assertMultipleDocumentsMatch($expectedDocuments, $cursor); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-6-encrypting-a-document-greater-than-the-maximum-errors + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase6_EncryptingADocumentGreaterThanTheMaximumErrors(string $type, array $rangeOpts): void + { + if ($type === 'DecimalNoPrecision' || $type === 'DoubleNoPrecision') { + $this->markTestSkipped('Test is not applicable to "NoPrecision" types'); + } + + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('Value must be greater than or equal to the minimum value and less than or equal to the maximum value'); + $this->clientEncryption->encrypt(self::cast($type, 201), $encryptOpts); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-7-encrypting-a-value-of-a-different-type-errors + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase7_EncryptingAValueOfADifferentTypeErrors(string $type, array $rangeOpts): void + { + if ($type === 'DecimalNoPrecision' || $type === 'DoubleNoPrecision') { + /* Explicit encryption relies on min/max range options to check + * types and "NoPrecision" intentionally omits those options. */ + $this->markTestSkipped('Test is not applicable to DoubleNoPrecision and DecimalNoPrecision'); + } + + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts, + ]; + + $value = $type === 'Int' ? 6.0 : 6; + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('expected matching \'min\' and value type'); + $this->clientEncryption->encrypt($value, $encryptOpts); + } + + /** + * @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#case-8-setting-precision-errors-if-the-type-is-not-double-or-decimal128 + * @dataProvider provideTypeAndRangeOpts + */ + public function testCase8_SettingPrecisionErrorsIfTheTypeIsNotDoubleOrDecimal128(string $type, array $rangeOpts): void + { + if ($type === 'DecimalNoPrecision' || $type === 'DecimalPrecision' || $type === 'DoubleNoPrecision' || $type === 'DoublePrecision') { + $this->markTestSkipped('Test is not applicable to Double and Decimal types'); + } + + $this->setUpWithTypeAndRangeOpts($type, $rangeOpts); + + $encryptOpts = [ + 'keyId' => $this->key1Id, + 'algorithm' => ClientEncryption::ALGORITHM_RANGE, + 'contentionFactor' => 0, + 'rangeOpts' => $rangeOpts + ['precision' => 2], + ]; + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('expected \'precision\' to be set with double or decimal128 index'); + $this->clientEncryption->encrypt(self::cast($type, 6), $encryptOpts); + } + + private function assertMultipleDocumentsMatch(array $expectedDocuments, Iterator $actualDocuments): void + { + $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); + $mi->attachIterator(new ArrayIterator($expectedDocuments)); + $mi->attachIterator($actualDocuments); + + foreach ($mi as $documents) { + [$expectedDocument, $actualDocument] = $documents; + $this->assertNotNull($expectedDocument); + $this->assertNotNull($actualDocument); + + $this->assertDocumentsMatch($expectedDocument, $actualDocument); + } + } + + private static function cast(string $type, int $value): mixed + { + return match ($type) { + 'DecimalNoPrecision', 'DecimalPrecision' => new Decimal128((string) $value), + 'DoubleNoPrecision', 'DoublePrecision' => (double) $value, + 'Date' => new UTCDateTime($value), + 'Int' => $value, + 'Long' => new Int64($value), + default => throw new LogicException('Unsupported type: ' . $type), + }; + } +}