diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 508a5c3c0f..1647638045 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -594,11 +594,33 @@ export class CryptoConnection extends Connection { return; } + // Save sort or indexKeys based on the command being run + // the encrypt API serializes our JS objects to BSON to pass to the native code layer + // and then deserializes the encrypted result, the protocol level components + // of the command (ex. sort) are then converted to JS objects potentially losing + // import key order information. These fields are never encrypted so we can save the values + // from before the encryption and replace them after encryption has been performed + const sort: Map | null = cmd.find || cmd.findAndModify ? cmd.sort : null; + const indexKeys: Map[] | null = cmd.createIndexes + ? cmd.indexes.map((index: { key: Map }) => index.key) + : null; + autoEncrypter.encrypt(ns.toString(), cmd, options, (err, encrypted) => { if (err || encrypted == null) { callback(err, null); return; } + + // Replace the saved values + if (sort != null && (cmd.find || cmd.findAndModify)) { + encrypted.sort = sort; + } + if (indexKeys != null && cmd.createIndexes) { + for (const [offset, index] of indexKeys.entries()) { + encrypted.indexes[offset].key = index; + } + } + super.command(ns, encrypted, options, (err, response) => { if (err || response == null) { callback(err, response); diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index c979a64b7f..f1dfceeaa7 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -6,7 +6,7 @@ import type { OneOrMore } from '../mongo_types'; import { ReadPreference } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; -import { Callback, maxWireVersion, MongoDBNamespace, parseIndexOptions } from '../utils'; +import { Callback, isObject, maxWireVersion, MongoDBNamespace } from '../utils'; import { CollationOptions, CommandOperation, @@ -51,14 +51,17 @@ const VALID_INDEX_OPTIONS = new Set([ /** @public */ export type IndexDirection = -1 | 1 | '2d' | '2dsphere' | 'text' | 'geoHaystack' | number; - +function isIndexDirection(x: unknown): x is IndexDirection { + return ( + typeof x === 'number' || x === '2d' || x === '2dsphere' || x === 'text' || x === 'geoHaystack' + ); +} /** @public */ export type IndexSpecification = OneOrMore< | string | [string, IndexDirection] | { [key: string]: IndexDirection } - | [string, IndexDirection][] - | { [key: string]: IndexDirection }[] + | Map >; /** @public */ @@ -86,7 +89,7 @@ export interface IndexDescription > { collation?: CollationOptions; name?: string; - key: Document; + key: { [key: string]: IndexDirection } | Map; } /** @public */ @@ -130,23 +133,37 @@ export interface CreateIndexesOptions extends CommandOperationOptions { hidden?: boolean; } -function makeIndexSpec(indexSpec: IndexSpecification, options: any): IndexDescription { - const indexParameters = parseIndexOptions(indexSpec); - - // Generate the index name - const name = typeof options.name === 'string' ? options.name : indexParameters.name; - - // Set up the index - const finalIndexSpec: Document = { name, key: indexParameters.fieldHash }; +function isSingleIndexTuple(t: unknown): t is [string, IndexDirection] { + return Array.isArray(t) && t.length === 2 && isIndexDirection(t[1]); +} - // merge valid index options into the index spec - for (const optionName in options) { - if (VALID_INDEX_OPTIONS.has(optionName)) { - finalIndexSpec[optionName] = options[optionName]; +function makeIndexSpec( + indexSpec: IndexSpecification, + options?: CreateIndexesOptions +): IndexDescription { + const key: Map = new Map(); + + const indexSpecs = + !Array.isArray(indexSpec) || isSingleIndexTuple(indexSpec) ? [indexSpec] : indexSpec; + + // Iterate through array and handle different types + for (const spec of indexSpecs) { + if (typeof spec === 'string') { + key.set(spec, 1); + } else if (Array.isArray(spec)) { + key.set(spec[0], spec[1] ?? 1); + } else if (spec instanceof Map) { + for (const [property, value] of spec) { + key.set(property, value); + } + } else if (isObject(spec)) { + for (const [property, value] of Object.entries(spec)) { + key.set(property, value); + } } } - return finalIndexSpec as IndexDescription; + return { ...options, key }; } /** @internal */ @@ -183,7 +200,7 @@ export class CreateIndexesOperation< > extends CommandOperation { override options: CreateIndexesOptions; collectionName: string; - indexes: IndexDescription[]; + indexes: ReadonlyArray & { key: Map }>; constructor( parent: OperationParent, @@ -195,8 +212,22 @@ export class CreateIndexesOperation< this.options = options ?? {}; this.collectionName = collectionName; - - this.indexes = indexes; + this.indexes = indexes.map(userIndex => { + // Ensure the key is a Map to preserve index key ordering + const key = + userIndex.key instanceof Map ? userIndex.key : new Map(Object.entries(userIndex.key)); + const name = userIndex.name != null ? userIndex.name : Array.from(key).flat().join('_'); + const validIndexOptions = Object.fromEntries( + Object.entries({ ...userIndex }).filter(([optionName]) => + VALID_INDEX_OPTIONS.has(optionName) + ) + ); + return { + ...validIndexOptions, + name, + key + }; + }); } override execute( @@ -209,31 +240,6 @@ export class CreateIndexesOperation< const serverWireVersion = maxWireVersion(server); - // Ensure we generate the correct name if the parameter is not set - for (let i = 0; i < indexes.length; i++) { - // Did the user pass in a collation, check if our write server supports it - if (indexes[i].collation && serverWireVersion < 5) { - callback( - new MongoCompatibilityError( - `Server ${server.name}, which reports wire version ${serverWireVersion}, ` + - 'does not support collation' - ) - ); - return; - } - - if (indexes[i].name == null) { - const keys = []; - - for (const name in indexes[i].key) { - keys.push(`${name}_${indexes[i].key[name]}`); - } - - // Set the name - indexes[i].name = keys.join('_'); - } - } - const cmd: Document = { createIndexes: this.collectionName, indexes }; if (options.commitQuorum != null) { @@ -271,12 +277,6 @@ export class CreateIndexOperation extends CreateIndexesOperation { indexSpec: IndexSpecification, options?: CreateIndexesOptions ) { - // createIndex can be called with a variety of styles: - // coll.createIndex('a'); - // coll.createIndex({ a: 1 }); - // coll.createIndex([['a', 1]]); - // createIndexes is always called with an array of index spec objects - super(parent, collectionName, [makeIndexSpec(indexSpec, options)], options); } override execute( diff --git a/src/utils.ts b/src/utils.ts index abbdf02d1d..c770ed33a3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,7 +23,6 @@ import { import type { Explain } from './explain'; import type { MongoClient } from './mongo_client'; import type { CommandOperationOptions, OperationParent } from './operations/command'; -import type { IndexDirection, IndexSpecification } from './operations/indexes'; import type { Hint, OperationOptions } from './operations/operation'; import { PromiseProvider } from './promise_provider'; import { ReadConcern } from './read_concern'; @@ -104,63 +103,6 @@ export function normalizeHintField(hint?: Hint): Hint | undefined { return finalHint; } -interface IndexOptions { - name: string; - keys?: string[]; - fieldHash: Document; -} - -/** - * Create an index specifier based on - * @internal - */ -export function parseIndexOptions(indexSpec: IndexSpecification): IndexOptions { - const fieldHash: { [key: string]: IndexDirection } = {}; - const indexes = []; - let keys; - - // Get all the fields accordingly - if ('string' === typeof indexSpec) { - // 'type' - indexes.push(indexSpec + '_' + 1); - fieldHash[indexSpec] = 1; - } else if (Array.isArray(indexSpec)) { - indexSpec.forEach((f: any) => { - if ('string' === typeof f) { - // [{location:'2d'}, 'type'] - indexes.push(f + '_' + 1); - fieldHash[f] = 1; - } else if (Array.isArray(f)) { - // [['location', '2d'],['type', 1]] - indexes.push(f[0] + '_' + (f[1] || 1)); - fieldHash[f[0]] = f[1] || 1; - } else if (isObject(f)) { - // [{location:'2d'}, {type:1}] - keys = Object.keys(f); - keys.forEach(k => { - indexes.push(k + '_' + (f as AnyOptions)[k]); - fieldHash[k] = (f as AnyOptions)[k]; - }); - } else { - // undefined (ignore) - } - }); - } else if (isObject(indexSpec)) { - // {location:'2d', type:1} - keys = Object.keys(indexSpec); - Object.entries(indexSpec).forEach(([key, value]) => { - indexes.push(key + '_' + value); - fieldHash[key] = value; - }); - } - - return { - name: indexes.join('_'), - keys: keys, - fieldHash: fieldHash - }; -} - const TO_STRING = (object: unknown) => Object.prototype.toString.call(object); /** * Checks if arg is an Object: diff --git a/test/integration/client-side-encryption/driver.test.js b/test/integration/client-side-encryption/driver.test.js deleted file mode 100644 index 4d62a70bae..0000000000 --- a/test/integration/client-side-encryption/driver.test.js +++ /dev/null @@ -1,225 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); -const BSON = require('bson'); -const chai = require('chai'); - -const expect = chai.expect; -chai.use(require('chai-subset')); - -describe('Client Side Encryption Functional', function () { - const dataDbName = 'db'; - const dataCollName = 'coll'; - const keyVaultDbName = 'keyvault'; - const keyVaultCollName = 'datakeys'; - const keyVaultNamespace = `${keyVaultDbName}.${keyVaultCollName}`; - - const metadata = { - requires: { - mongodb: '>=4.2.0', - clientSideEncryption: true - } - }; - - it('CSFLE_KMS_PROVIDERS should be valid EJSON', function () { - if (process.env.CSFLE_KMS_PROVIDERS) { - /** - * The shape of CSFLE_KMS_PROVIDERS is as follows: - * - * interface CSFLE_kms_providers { - * aws: { - * accessKeyId: string; - * secretAccessKey: string; - * }; - * azure: { - * tenantId: string; - * clientId: string; - * clientSecret: string; - * }; - * gcp: { - * email: string; - * privateKey: string; - * }; - * local: { - * // EJSON handle converting this, its actually the canonical -> { $binary: { base64: string; subType: string } } - * // **NOTE**: The dollar sign has to be escaped when using this as an ENV variable - * key: Binary; - * } - * } - */ - expect(() => BSON.EJSON.parse(process.env.CSFLE_KMS_PROVIDERS)).to.not.throw(SyntaxError); - } else { - this.skip(); - } - }); - - describe('Collection', metadata, function () { - describe('#bulkWrite()', metadata, function () { - context('when encryption errors', function () { - let client; - - beforeEach(function () { - client = this.configuration.newClient( - {}, - { - autoEncryption: { - keyVaultNamespace: 'test.keyvault', - kmsProviders: { - local: { - key: 'A'.repeat(128) - } - }, - encryptedFieldsMap: { - 'test.coll': { - fields: [ - { - path: 'ssn', - keyId: new BSON.UUID('23f786b4-1d39-4c36-ae88-70a663321ec9').toBinary(), - bsonType: 'string' - } - ] - } - } - } - } - ); - }); - - afterEach(async function () { - await client.close(); - }); - - it('bubbles up the error', metadata, async function () { - try { - await client - .db('test') - .collection('coll') - .bulkWrite([{ insertOne: { ssn: 'foo' } }]); - expect.fail('expected error to be thrown'); - } catch (error) { - expect(error.name).to.equal('MongoBulkWriteError'); - } - }); - }); - }); - }); - - describe('BSON Options', function () { - beforeEach(function () { - this.client = this.configuration.newClient(); - - const noop = () => {}; - function encryptSchema(keyId, bsonType) { - return { - encrypt: { - bsonType, - algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random', - keyId: [keyId] - } - }; - } - - let encryption; - let dataDb; - let keyVaultDb; - - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; - const kmsProviders = this.configuration.kmsProviders(crypto.randomBytes(96)); - return this.client - .connect() - .then(() => { - encryption = new mongodbClientEncryption.ClientEncryption(this.client, { - bson: BSON, - keyVaultNamespace, - kmsProviders - }); - }) - .then(() => (dataDb = this.client.db(dataDbName))) - .then(() => (keyVaultDb = this.client.db(keyVaultDbName))) - .then(() => dataDb.dropCollection(dataCollName).catch(noop)) - .then(() => keyVaultDb.dropCollection(keyVaultCollName).catch(noop)) - .then(() => keyVaultDb.createCollection(keyVaultCollName)) - .then(() => encryption.createDataKey('local')) - .then(dataKey => { - const $jsonSchema = { - bsonType: 'object', - properties: { - a: encryptSchema(dataKey, 'int'), - b: encryptSchema(dataKey, 'int'), - c: encryptSchema(dataKey, 'long'), - d: encryptSchema(dataKey, 'double') - } - }; - return dataDb.createCollection(dataCollName, { - validator: { $jsonSchema } - }); - }) - .then(() => { - this.encryptedClient = this.configuration.newClient( - {}, - { - autoEncryption: { - keyVaultNamespace, - kmsProviders - } - } - ); - return this.encryptedClient.connect(); - }); - }); - - afterEach(function () { - return Promise.resolve() - .then(() => this.encryptedClient && this.encryptedClient.close()) - .then(() => this.client.close()); - }); - - const testCases = [ - {}, - { - promoteValues: true - }, - { - promoteValues: false - }, - { - promoteValues: true, - promoteLongs: false - }, - { - promoteValues: true, - promoteLongs: true - }, - { - bsonRegExp: true - }, - { - ignoreUndefined: true - } - ]; - - testCases.forEach(bsonOptions => { - const name = `should respect bson options ${JSON.stringify(bsonOptions)}`; - - it(name, metadata, function () { - const data = { - a: 12, - b: new BSON.Int32(12), - c: new BSON.Long(12), - d: new BSON.Double(12), - e: /[A-Za-z0-9]*/, - f: new BSON.BSONRegExp('[A-Za-z0-9]*'), - g: undefined - }; - - const expected = BSON.deserialize(BSON.serialize(data, bsonOptions), bsonOptions); - - const coll = this.encryptedClient.db(dataDbName).collection(dataCollName); - return Promise.resolve() - .then(() => coll.insertOne(data, bsonOptions)) - .then(result => coll.findOne({ _id: result.insertedId }, bsonOptions)) - .then(actual => expect(actual).to.containSubset(expected)); - }); - }); - }); -}); diff --git a/test/integration/client-side-encryption/driver.test.ts b/test/integration/client-side-encryption/driver.test.ts new file mode 100644 index 0000000000..ddb9bb01e8 --- /dev/null +++ b/test/integration/client-side-encryption/driver.test.ts @@ -0,0 +1,287 @@ +import { EJSON, UUID } from 'bson'; +import { expect } from 'chai'; +import * as crypto from 'crypto'; + +import { Collection, CommandStartedEvent, MongoClient } from '../../../src'; +import * as BSON from '../../../src/bson'; +import { ClientEncryption } from '../../tools/unified-spec-runner/schema'; + +const metadata = { + requires: { + mongodb: '>=4.2.0', + clientSideEncryption: true + } +}; + +describe('Client Side Encryption Functional', function () { + const dataDbName = 'db'; + const dataCollName = 'coll'; + const keyVaultDbName = 'keyvault'; + const keyVaultCollName = 'datakeys'; + const keyVaultNamespace = `${keyVaultDbName}.${keyVaultCollName}`; + + it('CSFLE_KMS_PROVIDERS should be valid EJSON', function () { + const CSFLE_KMS_PROVIDERS = process.env.CSFLE_KMS_PROVIDERS; + if (typeof CSFLE_KMS_PROVIDERS === 'string') { + /** + * The shape of CSFLE_KMS_PROVIDERS is as follows: + * + * ```ts + * interface CSFLE_kms_providers { + * aws: { + * accessKeyId: string; + * secretAccessKey: string; + * }; + * azure: { + * tenantId: string; + * clientId: string; + * clientSecret: string; + * }; + * gcp: { + * email: string; + * privateKey: string; + * }; + * local: { + * // EJSON handle converting this, its actually the canonical -> { $binary: { base64: string; subType: string } } + * // **NOTE**: The dollar sign has to be escaped when using this as an ENV variable + * key: Binary; + * } + * } + * ``` + */ + expect(() => EJSON.parse(CSFLE_KMS_PROVIDERS)).to.not.throw(SyntaxError); + } else { + this.skip(); + } + }); + + describe('Collection', metadata, function () { + describe('#bulkWrite()', metadata, function () { + context('when encryption errors', function () { + let client: MongoClient; + + beforeEach(function () { + client = this.configuration.newClient( + {}, + { + autoEncryption: { + keyVaultNamespace: 'test.keyvault', + kmsProviders: { + local: { + key: 'A'.repeat(128) + } + }, + encryptedFieldsMap: { + 'test.coll': { + fields: [ + { + path: 'ssn', + keyId: new UUID('23f786b4-1d39-4c36-ae88-70a663321ec9').toBinary(), + bsonType: 'string' + } + ] + } + } + } + } + ); + }); + + afterEach(async function () { + await client.close(); + }); + + it('bubbles up the error', metadata, async function () { + try { + await client + .db('test') + .collection('coll') + // @ts-expect-error: Incorrectly formatted bulkWrite to test error case + .bulkWrite([{ insertOne: { ssn: 'foo' } }]); + expect.fail('expected error to be thrown'); + } catch (error) { + expect(error.name).to.equal('MongoBulkWriteError'); + } + }); + }); + }); + }); + + describe('BSON Options', function () { + let client: MongoClient; + let encryptedClient: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient(); + + const encryptSchema = (keyId: unknown, bsonType: string) => ({ + encrypt: { + bsonType, + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random', + keyId: [keyId] + } + }); + + const mongodbClientEncryption = this.configuration.mongodbClientEncryption; + const kmsProviders = this.configuration.kmsProviders(crypto.randomBytes(96)); + + await client.connect(); + + const encryption: ClientEncryption = new mongodbClientEncryption.ClientEncryption(client, { + bson: BSON, + keyVaultNamespace, + kmsProviders + }); + + const dataDb = client.db(dataDbName); + const keyVaultDb = client.db(keyVaultDbName); + + await dataDb.dropCollection(dataCollName).catch(() => null); + await keyVaultDb.dropCollection(keyVaultCollName).catch(() => null); + await keyVaultDb.createCollection(keyVaultCollName); + const dataKey = await encryption.createDataKey('local'); + + const $jsonSchema = { + bsonType: 'object', + properties: { + a: encryptSchema(dataKey, 'int'), + b: encryptSchema(dataKey, 'int'), + c: encryptSchema(dataKey, 'long'), + d: encryptSchema(dataKey, 'double') + } + }; + + await dataDb.createCollection(dataCollName, { + validator: { $jsonSchema } + }); + + encryptedClient = this.configuration.newClient( + {}, + { autoEncryption: { keyVaultNamespace, kmsProviders } } + ); + + await encryptedClient.connect(); + }); + + afterEach(function () { + return Promise.resolve() + .then(() => encryptedClient?.close()) + .then(() => client?.close()); + }); + + const testCases = [ + {}, + { promoteValues: true }, + { promoteValues: false }, + { promoteValues: true, promoteLongs: false }, + { promoteValues: true, promoteLongs: true }, + { bsonRegExp: true }, + { ignoreUndefined: true } + ]; + + for (const bsonOptions of testCases) { + const name = `should respect bson options ${JSON.stringify(bsonOptions)}`; + + it(name, metadata, async function () { + const data = { + _id: new BSON.ObjectId(), + a: 12, + b: new BSON.Int32(12), + c: new BSON.Long(12), + d: new BSON.Double(12), + e: /[A-Za-z0-9]*/, + f: new BSON.BSONRegExp('[A-Za-z0-9]*'), + g: undefined + }; + + const expected = BSON.deserialize(BSON.serialize(data, bsonOptions), bsonOptions); + + const coll = encryptedClient.db(dataDbName).collection(dataCollName); + const result = await coll.insertOne(data, bsonOptions); + const actual = await coll.findOne({ _id: result.insertedId }, bsonOptions); + const gValue = actual?.g; + delete actual?.g; + + expect(actual).to.deep.equal(expected); + expect(gValue).to.equal(bsonOptions.ignoreUndefined ? data.g : null); + }); + } + }); + + describe('key order aware command properties', () => { + let client: MongoClient; + let collection: Collection; + + beforeEach(async function () { + if (!this.configuration.clientSideEncryption.enabled) { + return; + } + + const encryptionOptions = { + monitorCommands: true, + autoEncryption: { + keyVaultNamespace, + kmsProviders: { local: { key: 'A'.repeat(128) } } + } + }; + client = this.configuration.newClient({}, encryptionOptions); + collection = client.db(dataDbName).collection('keyOrder'); + }); + + afterEach(async () => { + if (client) await client.close(); + }); + + describe('find', () => { + it('should maintain ordered sort', metadata, async function () { + const events: CommandStartedEvent[] = []; + client.on('commandStarted', ev => events.push(ev)); + const sort = Object.freeze([ + Object.freeze(['1', 1] as const), + Object.freeze(['0', 1] as const) + ]); + // @ts-expect-error: Our findOne API does not accept readonly input + await collection.findOne({}, { sort }); + const findEvent = events.find(event => !!event.command.find); + expect(findEvent).to.have.property('commandName', 'find'); + expect(findEvent).to.have.nested.property('command.sort').deep.equal(new Map(sort)); + }); + }); + + describe('findAndModify', () => { + it('should maintain ordered sort', metadata, async function () { + const events: CommandStartedEvent[] = []; + client.on('commandStarted', ev => events.push(ev)); + const sort = Object.freeze([ + Object.freeze(['1', 1] as const), + Object.freeze(['0', 1] as const) + ]); + // @ts-expect-error: Our findOneAndUpdate API does not accept readonly input + await collection.findOneAndUpdate({}, { $setOnInsert: { a: 1 } }, { sort }); + const findAndModifyEvent = events.find(event => !!event.command.findAndModify); + expect(findAndModifyEvent).to.have.property('commandName', 'findAndModify'); + expect(findAndModifyEvent) + .to.have.nested.property('command.sort') + .deep.equal(new Map(sort)); + }); + }); + + describe('createIndexes', () => { + it('should maintain ordered index keys', metadata, async function () { + const events: CommandStartedEvent[] = []; + client.on('commandStarted', ev => events.push(ev)); + const indexDescription = Object.freeze([ + Object.freeze(['1', 1] as const), + Object.freeze(['0', 1] as const) + ]); + // @ts-expect-error: Our createIndex API does not accept readonly input + await collection.createIndex(indexDescription, { name: 'myIndex' }); + const createIndexEvent = events.find(event => !!event.command.createIndexes); + expect(createIndexEvent).to.have.property('commandName', 'createIndexes'); + expect(createIndexEvent).to.have.nested.property('command.indexes').that.has.lengthOf(1); + const index = createIndexEvent?.command.indexes[0]; + expect(index.key).to.deep.equal(new Map(indexDescription)); + }); + }); + }); +}); diff --git a/test/tools/spec-runner/index.js b/test/tools/spec-runner/index.js index 480485159a..dc322e269b 100644 --- a/test/tools/spec-runner/index.js +++ b/test/tools/spec-runner/index.js @@ -485,6 +485,22 @@ function validateExpectations(commandEvents, spec, savedSessionData) { actualCommand.sort = { [expectedKey]: actualCommand.sort.get(expectedKey) }; } + if (expectedCommand.createIndexes) { + // TODO(NODE-3235): This is a workaround that works because all indexes in the specs + // are objects with one key; ideally we'd want to adjust the spec definitions + // to indicate whether order matters for any given key and set general + // expectations accordingly + for (const [i, dbIndex] of actualCommand.indexes.entries()) { + expect(Object.keys(expectedCommand.indexes[i].key)).to.have.lengthOf(1); + expect(dbIndex.key).to.be.instanceOf(Map); + expect(dbIndex.key.size).to.equal(1); + } + actualCommand.indexes = actualCommand.indexes.map(dbIndex => ({ + ...dbIndex, + key: Object.fromEntries(dbIndex.key) + })); + } + expect(actualCommand).withSessionData(savedSessionData).to.matchMongoSpec(expectedCommand); } } @@ -505,6 +521,11 @@ function normalizeCommandShapes(commands) { if (def.command.sort) { output.command.sort = def.command.sort; } + if (def.command.createIndexes) { + for (const [i, dbIndex] of output.command.indexes.entries()) { + dbIndex.key = def.command.indexes[i].key; + } + } return output; }); } diff --git a/test/tools/spec-runner/matcher.js b/test/tools/spec-runner/matcher.js index 757b049d4a..6ea9b5a0cd 100644 --- a/test/tools/spec-runner/matcher.js +++ b/test/tools/spec-runner/matcher.js @@ -118,6 +118,10 @@ function generateMatchAndDiffSpecialCase(key, expectedObj, actualObj, metadata) function generateMatchAndDiff(expected, actual, metadata) { const typeOfExpected = typeof expected; + if (typeOfExpected === 'object' && expected._bsontype === 'Int32' && typeof actual === 'number') { + return { match: expected.value === actual, expected, actual }; + } + if (typeOfExpected !== typeof actual) { return { match: false, expected, actual }; } diff --git a/test/tools/unified-spec-runner/match.ts b/test/tools/unified-spec-runner/match.ts index f860d8fbed..e615d4a421 100644 --- a/test/tools/unified-spec-runner/match.ts +++ b/test/tools/unified-spec-runner/match.ts @@ -166,6 +166,19 @@ export function resultCheck( expect(actual[key]).to.have.all.keys(expectedSortKey); const objFromActual = { [expectedSortKey]: actual[key].get(expectedSortKey) }; resultCheck(objFromActual, value, entities, path, checkExtraKeys); + } else if (key === 'createIndexes') { + for (const [i, userIndex] of actual.indexes.entries()) { + expect(expected).to.have.nested.property(`.indexes[${i}].key`).to.be.a('object'); + // @ts-expect-error: Not worth narrowing to a document + expect(Object.keys(expected.indexes[i].key)).to.have.lengthOf(1); + expect(userIndex).to.have.property('key').that.is.instanceOf(Map); + expect( + userIndex.key.size, + 'Test input is JSON and cannot correctly test more than 1 key' + ).to.equal(1); + userIndex.key = Object.fromEntries(userIndex.key); + } + resultCheck(actual[key], value, entities, path, checkExtraKeys); } else { resultCheck(actual[key], value, entities, path, checkExtraKeys); } diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index 6062011568..7733dcb7c2 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -76,14 +76,19 @@ operations.set('assertIndexNotExists', async ({ operation, client }) => { const collection = client .db(operation.arguments.databaseName) .collection(operation.arguments.collectionName); + + const listIndexCursor = collection.listIndexes(); + let indexes; try { - expect(await collection.indexExists(operation.arguments.indexName)).to.be.true; + indexes = await listIndexCursor.toArray(); } catch (error) { if (error.code === 26 || error.message.includes('ns does not exist')) { return; } - throw error; + // Error will always exist here, this makes the output show what caused an issue with assertIndexNotExists + expect(error).to.not.exist; } + expect(indexes.map(({ name }) => name)).to.not.include(operation.arguments.indexName); }); operations.set('assertDifferentLsidOnLastTwoCommands', async ({ entities, operation }) => { diff --git a/test/tools/unified-spec-runner/runner.ts b/test/tools/unified-spec-runner/runner.ts index a5bed8e474..5f37f3467f 100644 --- a/test/tools/unified-spec-runner/runner.ts +++ b/test/tools/unified-spec-runner/runner.ts @@ -40,7 +40,7 @@ async function terminateOpenTransactions(client: MongoClient) { * @param skipFilter - a function that returns null if the test should be run, * or a skip reason if the test should be skipped */ -export async function runUnifiedTest( +async function runUnifiedTest( ctx: Mocha.Context, unifiedSuite: uni.UnifiedSuite, test: uni.Test, @@ -256,8 +256,8 @@ export function runUnifiedSuite( ): void { for (const unifiedSuite of specTests) { context(String(unifiedSuite.description), function () { - for (const test of unifiedSuite.tests) { - it(String(test.description), async function () { + for (const [index, test] of unifiedSuite.tests.entries()) { + it(String(test.description === '' ? `Test ${index}` : test.description), async function () { await runUnifiedTest(this, unifiedSuite, test, skipFilter); }); } diff --git a/test/tools/unified-spec-runner/schema.ts b/test/tools/unified-spec-runner/schema.ts index 629753a303..af4033f5bd 100644 --- a/test/tools/unified-spec-runner/schema.ts +++ b/test/tools/unified-spec-runner/schema.ts @@ -299,7 +299,7 @@ export type TestFilter = (test: Test, ctx: TestConfiguration) => string | false; export interface ClientEncryption { // eslint-disable-next-line @typescript-eslint/no-misused-new new (client: MongoClient, options: any): ClientEncryption; - createDataKey(provider, options): Promise; + createDataKey(provider, options?: Document): Promise; rewrapManyDataKey(filter, options): Promise; deleteKey(id): Promise; getKey(id): Promise; diff --git a/test/types/community/createIndex.test-d.ts b/test/types/community/createIndex.test-d.ts index 5462948ff9..f1802cb762 100644 --- a/test/types/community/createIndex.test-d.ts +++ b/test/types/community/createIndex.test-d.ts @@ -11,3 +11,23 @@ const indexName = collection.createIndex({}, options); expectType>(indexName); expectType(options.partialFilterExpression); + +// One +collection.createIndex('someKey'); +collection.createIndex(['someKey', 1]); +collection.createIndex(new Map([['someKey', 1]])); +collection.createIndex({ a: 1, b: -1 }); +collection.createIndex({ a: '2dsphere', b: -1 }); +// OrMore +collection.createIndex(['someKey']); +collection.createIndex([['someKey', 1]]); +collection.createIndex([new Map([['someKey', 1]])]); +collection.createIndex([{ a: 1, b: -1 }]); +collection.createIndex([ + { a: '2dsphere', b: -1 }, + { a: 'geoHaystack', b: 1 } +]); +collection.createIndex(['a', ['b', 1], { a: 'geoHaystack', b: 1 }, new Map([['someKey', 1]])]); + +// @ts-expect-error: CreateIndexes now asserts the object value types as of NODE-3517 +collection.createIndexes([{ key: { a: 34n } }]); diff --git a/test/unit/operations/indexes.test.ts b/test/unit/operations/indexes.test.ts new file mode 100644 index 0000000000..67c82a1c1b --- /dev/null +++ b/test/unit/operations/indexes.test.ts @@ -0,0 +1,151 @@ +import { expect } from 'chai'; + +import { + CreateIndexesOptions, + CreateIndexOperation, + IndexDirection +} from '../../../src/operations/indexes'; +import { ns } from '../../../src/utils'; + +describe('class CreateIndexOperation', () => { + const testCases = [ + { + description: 'single string', + input: 'sample_index', + mapData: new Map([['sample_index', 1]]), + name: 'sample_index_1' + }, + { + description: 'single [string, IndexDirection]', + input: ['sample_index', -1], + mapData: new Map([['sample_index', -1]]), + name: 'sample_index_-1' + }, + { + description: 'array of strings', + input: ['sample_index1', 'sample_index2', 'sample_index3'], + mapData: new Map([ + ['sample_index1', 1], + ['sample_index2', 1], + ['sample_index3', 1] + ]), + name: 'sample_index1_1_sample_index2_1_sample_index3_1' + }, + { + description: 'array of [string, IndexDirection]', + input: [ + ['sample_index1', -1], + ['sample_index2', 1], + ['sample_index3', '2d'] + ], + mapData: new Map([ + ['sample_index1', -1], + ['sample_index2', 1], + ['sample_index3', '2d'] + ]), + name: 'sample_index1_-1_sample_index2_1_sample_index3_2d' + }, + { + description: 'single { [key: string]: IndexDirection }', + input: { x: 1 }, + mapData: new Map([['x', 1]]), + name: 'x_1' + }, + { + description: 'array of { [key: string]: IndexDirection }', + input: [{ sample_index1: -1 }, { sample_index2: 1 }, { sample_index3: '2d' }], + mapData: new Map([ + ['sample_index1', -1], + ['sample_index2', 1], + ['sample_index3', '2d'] + ]), + name: 'sample_index1_-1_sample_index2_1_sample_index3_2d' + }, + { + description: + 'mixed array of [string, [string, IndexDirection], { [key: string]: IndexDirection }, Map]', + input: [ + 'sample_index1', + ['sample_index2', -1], + { sample_index3: '2d' }, + new Map([['sample_index4', '2dsphere']]) + ], + mapData: new Map([ + ['sample_index1', 1], + ['sample_index2', -1], + ['sample_index3', '2d'], + ['sample_index4', '2dsphere'] + ]), + name: 'sample_index1_1_sample_index2_-1_sample_index3_2d_sample_index4_2dsphere' + }, + { + description: 'array of Map', + input: [ + new Map([['sample_index1', 1]]), + new Map([['sample_index2', -1]]), + new Map([['sample_index3', '2d']]) + ], + mapData: new Map([ + ['sample_index1', 1], + ['sample_index2', -1], + ['sample_index3', '2d'] + ]), + name: 'sample_index1_1_sample_index2_-1_sample_index3_2d' + }, + { + description: 'single Map', + input: new Map([['sample_index', -1]]), + mapData: new Map([['sample_index', -1]]), + name: 'sample_index_-1' + } + ]; + + const makeIndexOperation = (input, options: CreateIndexesOptions = {}) => + new CreateIndexOperation({ s: { namespace: ns('a.b') } }, 'b', input, options); + + describe('#constructor()', () => { + for (const { description, input, mapData, name } of testCases) { + it(`should create fieldHash correctly when input is: ${description}`, () => { + const realOutput = makeIndexOperation(input); + expect(realOutput.indexes[0].key).to.deep.equal(mapData); + }); + + it(`should set name correctly if none provided with ${description} input `, () => { + const realOutput = makeIndexOperation(input); + expect(realOutput.indexes[0].name).to.equal(name); + }); + } + + it('should not generate a name if one is provided', () => { + const realOutput = makeIndexOperation({ a: 1, b: 1 }, { name: 'MyIndex' }); + expect(realOutput.indexes).to.be.an('array'); + expect(realOutput.indexes).to.have.nested.property('[0].name', 'MyIndex'); + }); + + it('should keep numerical keys in chronological ordering when using Map input type', () => { + const desiredMapData = new Map([ + ['2', -1], + ['1', 1] + ]); + const realOutput = makeIndexOperation(desiredMapData); + const desiredName = '2_-1_1_1'; + expect(realOutput.indexes[0].key).to.deep.equal(desiredMapData); + expect(realOutput.indexes[0].name).to.equal(desiredName); + }); + + it('should omit options that are not in the permitted list', () => { + const indexOutput = makeIndexOperation( + { a: 1 }, + // @ts-expect-error: Testing bad options get filtered + { randomOptionThatWillNeverBeAdded: true, storageEngine: { iLoveJavascript: 1 } } + ); + expect(indexOutput.indexes).to.have.lengthOf(1); + expect(indexOutput.indexes[0]).to.have.property('key').that.is.instanceOf(Map); + expect(indexOutput.indexes[0]).to.have.property('name', 'a_1'); + expect(indexOutput.indexes[0]) + .to.have.property('storageEngine') + .that.deep.equals({ iLoveJavascript: 1 }); + expect(indexOutput.indexes[0]).to.not.have.property('randomOptionThatWillNeverBeAdded'); + }); + }); +});