diff --git a/src/client-side-encryption/auto_encrypter.ts b/src/client-side-encryption/auto_encrypter.ts index 5ac3945f5e4..47c7ff62901 100644 --- a/src/client-side-encryption/auto_encrypter.ts +++ b/src/client-side-encryption/auto_encrypter.ts @@ -395,7 +395,7 @@ export class AutoEncrypter { socketOptions: autoSelectSocketOptions(this._client.options) }); - return deserialize(await stateMachine.execute(this, context), { + return deserialize(await stateMachine.execute(this, context, options.timeoutContext), { promoteValues: false, promoteLongs: false }); @@ -416,7 +416,11 @@ export class AutoEncrypter { socketOptions: autoSelectSocketOptions(this._client.options) }); - return await stateMachine.execute(this, context); + return await stateMachine.execute( + this, + context, + options.timeoutContext?.csotEnabled() ? options.timeoutContext : undefined + ); } /** diff --git a/test/integration/client-side-encryption/driver.test.ts b/test/integration/client-side-encryption/driver.test.ts index 71c3cbd858d..202501fad22 100644 --- a/test/integration/client-side-encryption/driver.test.ts +++ b/test/integration/client-side-encryption/driver.test.ts @@ -1,12 +1,23 @@ import { EJSON, UUID } from 'bson'; import { expect } from 'chai'; import * as crypto from 'crypto'; +import * as sinon from 'sinon'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { ClientEncryption } from '../../../src/client-side-encryption/client_encryption'; -import { type Collection, type CommandStartedEvent, type MongoClient } from '../../mongodb'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { StateMachine } from '../../../src/client-side-encryption/state_machine'; +import { + type Collection, + type CommandStartedEvent, + Connection, + CSOTTimeoutContext, + type KMSProviders, + type MongoClient, + MongoOperationTimeoutError +} from '../../mongodb'; import * as BSON from '../../mongodb'; -import { getEncryptExtraOptions } from '../../tools/utils'; +import { type FailPoint, getEncryptExtraOptions, measureDuration, sleep } from '../../tools/utils'; const metadata = { requires: { @@ -471,3 +482,374 @@ describe('Range Explicit Encryption with JS native types', function () { }); }); }); + +describe('CSOT', function () { + describe('Auto encryption', function () { + let setupClient; + let keyVaultClient: MongoClient; + let dataKey; + + beforeEach(async function () { + keyVaultClient = this.configuration.newClient(); + await keyVaultClient.connect(); + await keyVaultClient.db('keyvault').collection('datakeys'); + const clientEncryption = new ClientEncryption(keyVaultClient, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: getKmsProviders() + }); + dataKey = await clientEncryption.createDataKey('local'); + setupClient = this.configuration.newClient(); + await setupClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['find'], + blockConnection: true, + blockTimeMS: 2000 + } + } as FailPoint); + }); + + afterEach(async function () { + await keyVaultClient.close(); + await setupClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off' + } as FailPoint); + await setupClient.close(); + }); + + const getKmsProviders = (): KMSProviders => { + const result = EJSON.parse(process.env.CSFLE_KMS_PROVIDERS || '{}') as unknown as { + local: unknown; + }; + + return { local: result.local }; + }; + + const metadata: MongoDBMetadataUI = { + requires: { + mongodb: '>=4.2.0', + clientSideEncryption: true + } + }; + + context( + 'when an auto encrypted client is configured with timeoutMS and auto encryption takes longer than timeoutMS', + function () { + let encryptedClient: MongoClient; + const timeoutMS = 1000; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient( + {}, + { + autoEncryption: { + keyVaultClient, + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: getKmsProviders(), + schemaMap: { + 'test.test': { + bsonType: 'object', + encryptMetadata: { + keyId: [new UUID(dataKey)] + }, + properties: { + a: { + encrypt: { + bsonType: 'int', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random', + keyId: [new UUID(dataKey)] + } + } + } + } + } + }, + timeoutMS + } + ); + await encryptedClient.connect(); + }); + + afterEach(async function () { + await encryptedClient.close(); + }); + + it('the command should fail due to a timeout error', metadata, async function () { + const { duration, result: error } = await measureDuration(() => + encryptedClient + .db('test') + .collection('test') + .insertOne({ a: 1 }) + .catch(e => e) + ); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + }); + } + ); + + context( + 'when an auto encrypted client is not configured with timeoutMS and auto encryption is delayed', + function () { + let encryptedClient: MongoClient; + beforeEach(async function () { + encryptedClient = this.configuration.newClient( + {}, + { + autoEncryption: { + keyVaultClient, + keyVaultNamespace: 'admin.datakeys', + kmsProviders: getKmsProviders() + } + } + ); + }); + + afterEach(async function () { + await encryptedClient.close(); + }); + + it('the command succeeds', metadata, async function () { + await encryptedClient.db('test').collection('test').aggregate([]).toArray(); + }); + } + ); + }); + + describe('State machine', function () { + const stateMachine = new StateMachine({} as any); + + const timeoutContext = () => { + return new CSOTTimeoutContext({ + timeoutMS: 1000, + serverSelectionTimeoutMS: 30000 + }); + }; + + const timeoutMS = 1000; + + const metadata: MongoDBMetadataUI = { + requires: { + mongodb: '>=4.2.0' + } + }; + + describe('#markCommand', function () { + context( + 'when csot is enabled and markCommand() takes longer than the remaining timeoutMS', + function () { + let encryptedClient: MongoClient; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient( + {}, + { + timeoutMS + } + ); + await encryptedClient.connect(); + + const stub = sinon + // @ts-expect-error accessing private method + .stub(Connection.prototype, 'sendCommand') + .callsFake(async function* (...args) { + await sleep(1000); + yield* stub.wrappedMethod.call(this, ...args); + }); + }); + + afterEach(async function () { + await encryptedClient?.close(); + sinon.restore(); + }); + + it('the command should fail due to a timeout error', metadata, async function () { + const { duration, result: error } = await measureDuration(() => + stateMachine + .markCommand( + encryptedClient, + 'test.test', + BSON.serialize({ ping: 1 }), + timeoutContext() + ) + .catch(e => e) + ); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + }); + } + ); + }); + + describe('#fetchKeys', function () { + let setupClient; + + beforeEach(async function () { + setupClient = this.configuration.newClient(); + await setupClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['find'], + blockConnection: true, + blockTimeMS: 2000 + } + } as FailPoint); + }); + + afterEach(async function () { + await setupClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off' + } as FailPoint); + await setupClient.close(); + }); + + context( + 'when csot is enabled and fetchKeys() takes longer than the remaining timeoutMS', + function () { + let encryptedClient; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient( + {}, + { + timeoutMS + } + ); + await encryptedClient.connect(); + }); + + afterEach(async function () { + await encryptedClient?.close(); + }); + + it('the command should fail due to a timeout error', metadata, async function () { + const { duration, result: error } = await measureDuration(() => + stateMachine + .fetchKeys(encryptedClient, 'test.test', BSON.serialize({ a: 1 }), timeoutContext()) + .catch(e => e) + ); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + }); + } + ); + + context('when csot is not enabled and fetchKeys() is delayed', function () { + let encryptedClient; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient(); + await encryptedClient.connect(); + }); + + afterEach(async function () { + await encryptedClient?.close(); + }); + + it('the command succeeds', metadata, async function () { + await stateMachine.fetchKeys(encryptedClient, 'test.test', BSON.serialize({ a: 1 })); + }); + }); + }); + + describe('#fetchCollectionInfo', function () { + let setupClient; + + beforeEach(async function () { + setupClient = this.configuration.newClient(); + await setupClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { + failCommands: ['listCollections'], + blockConnection: true, + blockTimeMS: 2000 + } + } as FailPoint); + }); + + afterEach(async function () { + await setupClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off' + } as FailPoint); + await setupClient.close(); + }); + + context( + 'when csot is enabled and fetchCollectionInfo() takes longer than the remaining timeoutMS', + metadata, + function () { + let encryptedClient: MongoClient; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient( + {}, + { + timeoutMS + } + ); + await encryptedClient.connect(); + }); + + afterEach(async function () { + await encryptedClient?.close(); + }); + + it('the command should fail due to a timeout error', metadata, async function () { + const { duration, result: error } = await measureDuration(() => + stateMachine + .fetchCollectionInfo(encryptedClient, 'test.test', { a: 1 }, timeoutContext()) + .catch(e => e) + ); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + }); + } + ); + + context( + 'when csot is not enabled and fetchCollectionInfo() is delayed', + metadata, + function () { + let encryptedClient: MongoClient; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient(); + await encryptedClient.connect(); + }); + + afterEach(async function () { + await encryptedClient?.close(); + }); + + it('the command succeeds', metadata, async function () { + await stateMachine.fetchCollectionInfo(encryptedClient, 'test.test', { a: 1 }); + }); + } + ); + }); + }); +}); diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts index 146a2585c52..c7d5173a50e 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts @@ -3,6 +3,8 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { expect } from 'chai'; +import * as os from 'os'; +import * as path from 'path'; import * as semver from 'semver'; import * as sinon from 'sinon'; import { Readable } from 'stream'; @@ -125,10 +127,15 @@ describe('CSOT spec prose tests', function () { let childProcess: ChildProcess; beforeEach(async function () { - childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], { - stdio: 'ignore', - detached: true - }); + const pidFile = path.join(os.tmpdir(), new ObjectId().toHexString()); + childProcess = spawn( + 'mongocryptd', + ['--port', mongocryptdTestPort, '--ipv6', '--pidfilepath', pidFile], + { + stdio: 'ignore', + detached: true + } + ); childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error)); client = new MongoClient(`mongodb://localhost:${mongocryptdTestPort}/?timeoutMS=1000`, { @@ -145,6 +152,7 @@ describe('CSOT spec prose tests', function () { it('maxTimeMS is not set', async function () { const commandStarted = []; client.on('commandStarted', ev => commandStarted.push(ev)); + await client.connect(); await client .db('admin') .command({ ping: 1 }) diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts index 7387099a7f1..90b04e9a3ed 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.unit.test.ts @@ -13,6 +13,7 @@ import { promisify } from 'util'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { StateMachine } from '../../../src/client-side-encryption/state_machine'; import { + Connection, ConnectionPool, CSOTTimeoutContext, type MongoClient, @@ -21,6 +22,7 @@ import { TimeoutContext, Topology } from '../../mongodb'; +import { measureDuration, sleep } from '../../tools/utils'; import { createTimerSandbox } from '../../unit/timer_sandbox'; // TODO(NODE-5824): Implement CSOT prose tests @@ -181,8 +183,68 @@ describe('CSOT spec unit tests', function () { }); }); - // TODO(NODE-6390): Add timeoutMS support to Auto Encryption - it.skip('The remaining timeoutMS value should apply to commands sent to mongocryptd as part of automatic encryption.', () => {}); + describe('Auto Encryption', function () { + context( + 'when an auto encrypted client is configured with timeoutMS and the command takes longer than timeoutMS', + function () { + let encryptedClient; + const timeoutMS = 500; + + beforeEach(async function () { + encryptedClient = this.configuration.newClient( + {}, + { + autoEncryption: { + extraOptions: { + mongocryptdBypassSpawn: true, + mongocryptdURI: 'mongodb://localhost:27017/db?serverSelectionTimeoutMS=1000', + mongocryptdSpawnArgs: [ + '--pidfilepath=bypass-spawning-mongocryptd.pid', + '--port=27017' + ] + }, + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }, + timeoutMS + } + ); + await encryptedClient.connect(); + + const stub = sinon + // @ts-expect-error accessing private method + .stub(Connection.prototype, 'sendCommand') + .callsFake(async function* (...args) { + await sleep(timeoutMS + 50); + yield* stub.wrappedMethod.call(this, ...args); + }); + }); + + afterEach(async function () { + await encryptedClient?.close(); + sinon.restore(); + }); + + it( + 'the command should fail due to a timeout error', + { requires: { mongodb: '>=4.2' } }, + async function () { + const { duration, result: error } = await measureDuration(() => + encryptedClient + .db() + .command({ ping: 1 }) + .catch(e => e) + ); + expect(error).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + } + ); + } + ); + }); }); context.skip('Background Connection Pooling', function () { diff --git a/test/integration/server-discovery-and-monitoring/server_description.test.ts b/test/integration/server-discovery-and-monitoring/server_description.test.ts index 0a3c7eecbf6..60aa4614055 100644 --- a/test/integration/server-discovery-and-monitoring/server_description.test.ts +++ b/test/integration/server-discovery-and-monitoring/server_description.test.ts @@ -1,8 +1,10 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { expect } from 'chai'; +import * as os from 'os'; +import * as path from 'path'; -import { MongoClient } from '../../mongodb'; +import { MongoClient, ObjectId } from '../../mongodb'; describe('class ServerDescription', function () { describe('when connecting to mongocryptd', { requires: { mongodb: '>=4.4' } }, function () { @@ -11,10 +13,15 @@ describe('class ServerDescription', function () { let childProcess: ChildProcess; beforeEach(async function () { - childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], { - stdio: 'ignore', - detached: true - }); + const pidFile = path.join(os.tmpdir(), new ObjectId().toHexString()); + childProcess = spawn( + 'mongocryptd', + ['--port', mongocryptdTestPort, '--ipv6', '--pidfilepath', pidFile], + { + stdio: 'ignore', + detached: true + } + ); childProcess.on('error', error => console.warn(this.currentTest?.fullTitle(), error)); client = new MongoClient(`mongodb://localhost:${mongocryptdTestPort}`); diff --git a/test/integration/sessions/sessions.prose.test.ts b/test/integration/sessions/sessions.prose.test.ts index 8f157c4fa75..82464ffbbdc 100644 --- a/test/integration/sessions/sessions.prose.test.ts +++ b/test/integration/sessions/sessions.prose.test.ts @@ -1,13 +1,16 @@ import { expect } from 'chai'; import { type ChildProcess, spawn } from 'child_process'; import { once } from 'events'; +import * as os from 'os'; +import * as path from 'path'; import { type Collection, type CommandStartedEvent, MongoClient, MongoDriverError, - MongoInvalidArgumentError + MongoInvalidArgumentError, + ObjectId } from '../../mongodb'; import { sleep } from '../../tools/utils'; @@ -131,10 +134,15 @@ describe('Sessions Prose Tests', () => { let childProcess: ChildProcess; before(() => { - childProcess = spawn('mongocryptd', ['--port', mongocryptdTestPort, '--ipv6'], { - stdio: 'ignore', - detached: true - }); + const pidFile = path.join(os.tmpdir(), new ObjectId().toHexString()); + childProcess = spawn( + 'mongocryptd', + ['--port', mongocryptdTestPort, '--ipv6', '--pidfilepath', pidFile], + { + stdio: 'ignore', + detached: true + } + ); childProcess.on('error', err => { console.warn('Sessions prose mongocryptd error:', err);