diff --git a/src/cmap/handshake/client_metadata.ts b/src/cmap/handshake/client_metadata.ts index 23dcaa4b0c3..295e1af078a 100644 --- a/src/cmap/handshake/client_metadata.ts +++ b/src/cmap/handshake/client_metadata.ts @@ -2,13 +2,30 @@ import * as os from 'os'; import * as process from 'process'; import { BSON, type Document, Int32 } from '../../bson'; -import { MongoInvalidArgumentError, MongoRuntimeError } from '../../error'; -import type { MongoOptions } from '../../mongo_client'; +import { MongoInvalidArgumentError } from '../../error'; +import type { DriverInfo, MongoOptions } from '../../mongo_client'; import { fileIsAccessible } from '../../utils'; // eslint-disable-next-line @typescript-eslint/no-require-imports const NODE_DRIVER_VERSION = require('../../../package.json').version; +/** @internal */ +export function isDriverInfoEqual(info1: DriverInfo, info2: DriverInfo): boolean { + /** for equality comparison, we consider "" as unset */ + const nonEmptyCmp = (s1: string | undefined, s2: string | undefined): boolean => { + s1 ||= undefined; + s2 ||= undefined; + + return s1 === s2; + }; + + return ( + nonEmptyCmp(info1.name, info2.name) && + nonEmptyCmp(info1.platform, info2.platform) && + nonEmptyCmp(info1.version, info2.version) + ); +} + /** * @public * @deprecated This interface will be made internal in the next major release. @@ -90,10 +107,7 @@ export class LimitedSizeDocument { } } -type MakeClientMetadataOptions = Pick< - MongoOptions, - 'appName' | 'driverInfo' | 'additionalDriverInfo' ->; +type MakeClientMetadataOptions = Pick; /** * From the specs: * Implementors SHOULD cumulatively update fields in the following order until the document is under the size limit: @@ -102,34 +116,28 @@ type MakeClientMetadataOptions = Pick< * 3. Omit the `env` document entirely. * 4. Truncate `platform`. -- special we do not truncate this field */ -export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMetadata { +export function makeClientMetadata( + driverInfoList: DriverInfo[], + { appName = '' }: MakeClientMetadataOptions +): ClientMetadata { const metadataDocument = new LimitedSizeDocument(512); - const { appName = '' } = options; // Add app name first, it must be sent if (appName.length > 0) { const name = Buffer.byteLength(appName, 'utf8') <= 128 - ? options.appName + ? appName : Buffer.from(appName, 'utf8').subarray(0, 128).toString('utf8'); metadataDocument.ifItFitsItSits('application', { name }); } - const { name = '', version = '', platform = '' } = options.driverInfo; - const driverInfo = { - name: name.length > 0 ? `nodejs|${name}` : 'nodejs', - version: version.length > 0 ? `${NODE_DRIVER_VERSION}|${version}` : NODE_DRIVER_VERSION + name: 'nodejs', + version: NODE_DRIVER_VERSION }; - if (options.additionalDriverInfo == null) { - throw new MongoRuntimeError( - 'Client options `additionalDriverInfo` must always default to an empty array' - ); - } - // This is where we handle additional driver info added after client construction. - for (const { name: n = '', version: v = '' } of options.additionalDriverInfo) { + for (const { name: n = '', version: v = '' } of driverInfoList) { if (n.length > 0) { driverInfo.name = `${driverInfo.name}|${n}`; } @@ -145,13 +153,10 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe } let runtimeInfo = getRuntimeInfo(); - if (platform.length > 0) { - runtimeInfo = `${runtimeInfo}|${platform}`; - } - - for (const { platform: p = '' } of options.additionalDriverInfo) { - if (p.length > 0) { - runtimeInfo = `${runtimeInfo}|${p}`; + // This is where we handle additional driver info added after client construction. + for (const { platform = '' } of driverInfoList) { + if (platform.length > 0) { + runtimeInfo = `${runtimeInfo}|${platform}`; } } @@ -210,7 +215,9 @@ async function getContainerMetadata() { * Re-add each metadata value. * Attempt to add new env container metadata, but keep old data if it does not fit. */ -export async function addContainerMetadata(originalMetadata: ClientMetadata) { +export async function addContainerMetadata( + originalMetadata: ClientMetadata +): Promise { const containerMetadata = await getContainerMetadata(); if (Object.keys(containerMetadata).length === 0) return originalMetadata; @@ -233,7 +240,7 @@ export async function addContainerMetadata(originalMetadata: ClientMetadata) { extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata); } - return extendedMetadata.toObject(); + return extendedMetadata.toObject() as ClientMetadata; } /** diff --git a/src/connection_string.ts b/src/connection_string.ts index edb1a6870a1..97b7d4d62cc 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -5,7 +5,6 @@ import { URLSearchParams } from 'url'; import type { Document } from './bson'; import { MongoCredentials } from './cmap/auth/mongo_credentials'; import { AUTH_MECHS_AUTH_SRC_EXTERNAL, AuthMechanism } from './cmap/auth/providers'; -import { addContainerMetadata, makeClientMetadata } from './cmap/handshake/client_metadata'; import { Compressor, type CompressorName } from './cmap/wire_protocol/compression'; import { Encrypter } from './encrypter'; import { @@ -535,16 +534,6 @@ export function parseOptions( } ); - // Set the default for the additional driver info. - mongoOptions.additionalDriverInfo = []; - - mongoOptions.metadata = makeClientMetadata(mongoOptions); - - mongoOptions.extendedMetadata = addContainerMetadata(mongoOptions.metadata).then( - undefined, - squashError - ); // rejections will be handled later - return mongoOptions; } diff --git a/src/mongo_client.ts b/src/mongo_client.ts index 1ff310822db..3f91b6e60ae 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -18,6 +18,7 @@ import type { Connection } from './cmap/connection'; import { addContainerMetadata, type ClientMetadata, + isDriverInfoEqual, makeClientMetadata } from './cmap/handshake/client_metadata'; import type { CompressorName } from './cmap/wire_protocol/compression'; @@ -430,12 +431,16 @@ export class MongoClient extends TypedEventEmitter implements | 'extendedMetadata' >; + private driverInfoList: DriverInfo[] = []; + constructor(url: string, options?: MongoClientOptions) { super(); this.on('error', noop); this.options = parseOptions(url, this, options); + this.appendMetadata(this.options.driverInfo); + const shouldSetLogger = Object.values(this.options.mongoLoggerOptions.componentSeverities).some( value => value !== SeverityLevel.OFF ); @@ -492,8 +497,13 @@ export class MongoClient extends TypedEventEmitter implements * @param driverInfo - Information about the application or library. */ appendMetadata(driverInfo: DriverInfo) { - this.options.additionalDriverInfo.push(driverInfo); - this.options.metadata = makeClientMetadata(this.options); + const isDuplicateDriverInfo = this.driverInfoList.some(info => + isDriverInfoEqual(info, driverInfo) + ); + if (isDuplicateDriverInfo) return; + + this.driverInfoList.push(driverInfo); + this.options.metadata = makeClientMetadata(this.driverInfoList, this.options); this.options.extendedMetadata = addContainerMetadata(this.options.metadata) .then(undefined, squashError) .then(result => result ?? {}); // ensure Promise diff --git a/test/integration/connection-monitoring-and-pooling/connection.test.ts b/test/integration/connection-monitoring-and-pooling/connection.test.ts index 7dfbe2be535..eb6a8b39821 100644 --- a/test/integration/connection-monitoring-and-pooling/connection.test.ts +++ b/test/integration/connection-monitoring-and-pooling/connection.test.ts @@ -49,10 +49,8 @@ describe('Connection', function () { ...commonConnectOptions, connectionType: Connection, ...this.configuration.options, - metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }), - extendedMetadata: addContainerMetadata( - makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }) - ) + metadata: makeClientMetadata([], {}), + extendedMetadata: addContainerMetadata(makeClientMetadata([], {})) }; let conn; @@ -74,10 +72,8 @@ describe('Connection', function () { connectionType: Connection, ...this.configuration.options, monitorCommands: true, - metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }), - extendedMetadata: addContainerMetadata( - makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }) - ) + metadata: makeClientMetadata([], {}), + extendedMetadata: addContainerMetadata(makeClientMetadata([], {})) }; let conn; @@ -108,10 +104,8 @@ describe('Connection', function () { connectionType: Connection, ...this.configuration.options, monitorCommands: true, - metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }), - extendedMetadata: addContainerMetadata( - makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }) - ) + metadata: makeClientMetadata([], {}), + extendedMetadata: addContainerMetadata(makeClientMetadata([], {})) }; let conn; diff --git a/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts b/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts index da5b98227be..be5e51645ee 100644 --- a/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts +++ b/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts @@ -1,10 +1,13 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; +import { type ClientMetadata, type DriverInfo } from '../../../mongodb'; +import { MongoClient as RawMongoClient } from '../../../src'; import { Connection, getFAASEnv, Int32, + isDriverInfoEqual, LEGACY_HELLO_COMMAND, type MongoClient } from '../../mongodb'; @@ -358,4 +361,593 @@ describe('Client Metadata Update Prose Tests', function () { }); } }); + + describe('Test 3: Multiple Successive Metadata Updates with Duplicate Data', function () { + const originalDriverInfo = { name: 'library', version: '1.2', platform: 'Library Platform' }; + let initialClientMetadata: ClientMetadata; + let updatedClientMetadata: ClientMetadata; + + // TODO(NODE-6599): mongodb-legacy adds additional client metadata, breaking these prose tests + let client: RawMongoClient; + + // | Case | Name | Version | Platform | + // | ---- | --------- | ------- | ------------------ | + // | 1 | library | 1.2 | Library Platform | + // | 2 | framework | 1.2 | Library Platform | + // | 3 | library | 2.0 | Library Platform | + // | 4 | library | 1.2 | Framework Platform | + // | 5 | framework | 2.0 | Library Platform | + // | 6 | framework | 1.2 | Framework Platform | + // | 7 | library | 2.0 | Framework Platform | + const tests = [ + { testCase: 1, name: 'library', version: '1.2', platform: 'Library Platform' }, + { testCase: 2, name: 'framework', version: '1.2', platform: 'Library Platform' }, + { testCase: 3, name: 'library', version: '2.0', platform: 'Library Platform' }, + { testCase: 4, name: 'library', version: '1.2', platform: 'Framework Platform' }, + { testCase: 5, name: 'framework', version: '2.0', platform: 'Library Platform' }, + { testCase: 6, name: 'framework', version: '1.2', platform: 'Framework Platform' }, + { testCase: 7, name: 'library', version: '2.0', platform: 'Framework Platform' } + ]; + + for (const { testCase, ...driverInfo } of tests) { + context(`Case ${testCase}: ${JSON.stringify(driverInfo)}`, function () { + // 1. Create a `MongoClient` instance with: + // - `maxIdleTimeMS` set to `1ms` + // 2. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + // 3. Send a `ping` command to the server and verify that the command succeeds. + // 4. Save intercepted `client` document as `updatedClientMetadata`. + // 5. Wait 5ms for the connection to become idle. + beforeEach(async function () { + client = new RawMongoClient(this.configuration.url(), { + maxIdleTimeMS: 1, + serverApi: this.configuration.serverApi + }); + client.appendMetadata(originalDriverInfo); + + sinon + .stub(Connection.prototype, 'command') + .callsFake(async function (ns, cmd, options, responseType) { + // @ts-expect-error: sinon will place wrappedMethod on the command method. + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + if (!initialClientMetadata) { + initialClientMetadata = cmd.client; + } else { + updatedClientMetadata = cmd.client; + } + } + return command(ns, cmd, options, responseType); + }); + + await client.db('test').command({ ping: 1 }); + await sleep(5); + }); + + afterEach(async function () { + await client.close(); + }); + + it('metadata is updated correctly, if necessary', async function () { + // 1. Append the `DriverInfoOptions` from the selected test case to the `MongoClient` metadata. + client.appendMetadata(driverInfo); + + // 2. Send a `ping` command to the server and verify: + // - The command succeeds. + await client.db('test').command({ ping: 1 }); + + // - The framework metadata is appended to the existing `DriverInfoOptions` in the `client.driver` fields of the `hello` + // command, with values separated by a pipe `|`. To simplify assertions in these tests, strip out the default driver info + // that is automatically added by the driver (ex: `metadata.name.split('|').slice(1).join('|')`). + + // - If the test case's DriverInfo is identical to the driver info from setup step 2 (test case 1): + // - Assert metadata.name is equal to `library` + // - Assert metadata.version is equal to `1.2` + // - Assert metadata.platform is equal to `LibraryPlatform` + // - Otherwise: + // - Assert metadata.name is equal to `library|` + // - Assert metadata.version is equal to `1.2|` + // - Assert metadata.platform is equal to `LibraryPlatform|` + const { driver, platform, ...updatedRest } = updatedClientMetadata; + const { driver: _driver, platform: _platform, ...originalRest } = initialClientMetadata; + + const extractParts = (s: string) => s.split('|').slice(1).join('|'); + + const actual = { + name: extractParts(driver.name), + version: extractParts(driver.version), + platform: extractParts(platform) + }; + + const expected = isDriverInfoEqual(driverInfo, originalDriverInfo) + ? originalDriverInfo + : { + name: `library|${driverInfo.name}`, + platform: `Library Platform|${driverInfo.platform}`, + version: `1.2|${driverInfo.version}` + }; + + expect(actual).to.deep.equal(expected); + + // All other subfields in the `client` document remain unchanged from `updatedClientMetadata`. + expect(updatedRest).to.deep.equal(originalRest); + }); + }); + } + }); + + describe('Test 4: Multiple Metadata Updates with Duplicate Data', function () { + let initialClientMetadata: ClientMetadata; + let updatedClientMetadata: ClientMetadata; + // TODO(NODE-6599): mongodb-legacy adds additional client metadata, breaking these prose tests + let client: RawMongoClient; + + afterEach(async function () { + await client.close(); + }); + + it('does not append duplicate metdata', async function () { + // 1. Create a `MongoClient` instance with: + // - `maxIdleTimeMS` set to `1ms` + + client = new RawMongoClient(this.configuration.url(), { + maxIdleTimeMS: 1, + serverApi: this.configuration.serverApi + }); + // 2. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + + client.appendMetadata({ + name: 'library', + version: '1.2', + platform: 'Library Platform' + }); + + // 3. Send a `ping` command to the server and verify that the command succeeds. + await client.db('test').command({ ping: 1 }); + + // 4. Wait 5ms for the connection to become idle. + await sleep(5); + + // 5. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ------------------ | + // | name | framework | + // | version | 2.0 | + // | platform | Framework Platform | + client.appendMetadata({ + name: 'framework', + version: '2.0', + platform: 'Framework Platform' + }); + + // 6. Send a `ping` command to the server and verify that the command succeeds. + // 7. Save intercepted `client` document as `clientMetadata`. + // 11. Save intercepted `client` document as `updatedClientMetadata`. + sinon + .stub(Connection.prototype, 'command') + .callsFake(async function (ns, cmd, options, responseType) { + // @ts-expect-error: sinon will place wrappedMethod on the command method. + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + if (!initialClientMetadata) { + initialClientMetadata = cmd.client; + } else { + updatedClientMetadata = cmd.client; + } + } + return command(ns, cmd, options, responseType); + }); + + await client.db('test').command({ ping: 1 }); + + // 8. Wait 5ms for the connection to become idle. + await sleep(5); + + // 9. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + client.appendMetadata({ + name: 'library', + version: '1.2', + platform: 'Library Platform' + }); + + // 10. Send a `ping` command to the server and verify that the command succeeds. + await client.db('test').command({ ping: 1 }); + + // 12. Assert that `clientMetadata` is identical to `updatedClientMetadata`. + expect(updatedClientMetadata).to.deep.equal(initialClientMetadata); + }); + }); + + describe('Test 5: Metadata is not appended if identical to initial metadata', function () { + let initialClientMetadata: ClientMetadata; + let updatedClientMetadata: ClientMetadata; + // TODO(NODE-6599): mongodb-legacy adds additional client metadata, breaking these prose tests + let client: RawMongoClient; + + // 1. Create a `MongoClient` instance with the following: + // - `maxIdleTimeMS` set to `1ms` + // - Wrapping library metadata: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + // 2. Send a `ping` command to the server and verify that the command succeeds. + // 3. Save intercepted `client` document as `initialClientMetadata`. + // 4. Wait 5ms for the connection to become idle. + beforeEach(async function () { + // 1. Create a `MongoClient` instance with: + // - `maxIdleTimeMS` set to `1ms` + // - `driverInfo` set to the following: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + client = new RawMongoClient(this.configuration.url(), { + maxIdleTimeMS: 1, + serverApi: this.configuration.serverApi, + driverInfo: { name: 'library', version: '1.2', platform: 'Library Platform' } + }); + + // 2. Send a `ping` command to the server and verify that the command succeeds. + // 3. Save intercepted `client` document as `clientMetadata`. + sinon + .stub(Connection.prototype, 'command') + .callsFake(async function (ns, cmd, options, responseType) { + // @ts-expect-error: sinon will place wrappedMethod on the command method. + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + if (!initialClientMetadata) { + initialClientMetadata = cmd.client; + } else { + updatedClientMetadata = cmd.client; + } + } + return command(ns, cmd, options, responseType); + }); + + await client.db('test').command({ ping: 1 }); + + // 4. Wait 5ms for the connection to become idle. + await sleep(5); + }); + + afterEach(async function () { + await client.close(); + }); + + it('does not append the duplicate metadata', async function () { + // 5. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + client.appendMetadata({ name: 'library', version: '1.2', platform: 'Library Platform' }); + + // 6. Send a `ping` command to the server and verify that the command succeeds. + // 7. Save intercepted `client` document as `updatedClientMetadata`. + await client.db('test').command({ ping: 1 }); + + // 8. Assert that `clientMetadata` is identical to `updatedClientMetadata`. + expect(initialClientMetadata).to.deep.equal(updatedClientMetadata); + }); + }); + + describe('Test 6: Metadata is not appended if identical to initial metadata (separated by non-identical metadata)', function () { + let clientMetadata: ClientMetadata; + let updatedClientMetadata: ClientMetadata; + // TODO(NODE-6599): mongodb-legacy adds additional client metadata, breaking these prose tests + let client: RawMongoClient; + + afterEach(async function () { + await client.close(); + }); + + it('does not append duplicate metdaata', async function () { + // 1. Create a `MongoClient` instance with: + // - `maxIdleTimeMS` set to `1ms` + // - `driverInfo` set to the following: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + + client = new RawMongoClient(this.configuration.url(), { + maxIdleTimeMS: 1, + driverInfo: { + name: 'library', + version: '1.2', + platform: 'Library Platform' + }, + serverApi: this.configuration.serverApi + }); + + // 2. Send a `ping` command to the server and verify that the command succeeds. + await client.db('test').command({ ping: 1 }); + + // 3. Wait 5ms for the connection to become idle. + await sleep(5); + + // 4. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ------------------ | + // | name | framework | + // | version | 2.0 | + // | platform | Framework Platform | + client.appendMetadata({ + name: 'framework', + version: '2.0', + platform: 'Framework Platform' + }); + + // 5. Send a `ping` command to the server and verify that the command succeeds. + // 6. Save intercepted `client` document as `clientMetadata`. + // 10. Save intercepted `client` document as `updatedClientMetadata`. + sinon + .stub(Connection.prototype, 'command') + .callsFake(async function (ns, cmd, options, responseType) { + // @ts-expect-error: sinon will place wrappedMethod on the command method. + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + if (!clientMetadata) { + clientMetadata = cmd.client; + } else { + updatedClientMetadata = cmd.client; + } + } + return command(ns, cmd, options, responseType); + }); + + await client.db('test').command({ ping: 1 }); + + // 7. Wait 5ms for the connection to become idle. + await sleep(5); + + // 8. Append the following `DriverInfoOptions` to the `MongoClient` metadata: + // | Field | Value | + // | -------- | ---------------- | + // | name | library | + // | version | 1.2 | + // | platform | Library Platform | + client.appendMetadata({ + name: 'library', + version: '1.2', + platform: 'Library Platform' + }); + + // 9. Send a `ping` command to the server and verify that the command succeeds. + await client.db('test').command({ ping: 1 }); + + // 11. Assert that `clientMetadata` is identical to `updatedClientMetadata`. + expect(updatedClientMetadata).to.deep.equal(clientMetadata); + }); + }); + + describe('Test 7: Empty strings are considered unset when appending duplicate metadata', function () { + let initialClientMetadata: ClientMetadata; + let updatedClientMetadata: ClientMetadata; + // TODO(NODE-6599): mongodb-legacy adds additional client metadata, breaking these prose tests + let client: RawMongoClient; + + afterEach(async function () { + await client.close(); + initialClientMetadata = undefined; + updatedClientMetadata = undefined; + }); + + const driverInfos: Array<{ + initial: DriverInfo; + appended: DriverInfo; + }> = [ + { + initial: { + name: undefined, + version: '1.2', + platform: 'Library Platform' + }, + appended: { + name: '', + version: '1.2', + platform: 'Library Platform' + } + }, + { + initial: { + name: 'library', + version: undefined, + platform: 'Library Platform' + }, + appended: { + name: 'library', + version: '', + platform: 'Library Platform' + } + }, + { + initial: { + name: 'library', + version: '1.2', + platform: undefined + }, + appended: { + name: 'library', + version: '1.2', + platform: '' + } + } + ]; + + for (const [metadata, index] of driverInfos.map((infos, i) => [infos, i] as const)) { + describe(`Test ${index + 1}`, function () { + it('does not appended duplicate metadata', async function () { + // 1. Create a `MongoClient` instance with: + // - `maxIdleTimeMS` set to `1ms` + client = new RawMongoClient(this.configuration.url(), { + maxIdleTimeMS: 1, + serverApi: this.configuration.serverApi + }); + + // 2. Append the `DriverInfoOptions` from the selected test case from the initial metadata section. + client.appendMetadata(metadata.initial); + + // 3. Send a `ping` command to the server and verify that the command succeeds. + // 4. Save intercepted `client` document as `initialClientMetadata`. + // 8. Store the response as `updatedClientMetadata`. + sinon + .stub(Connection.prototype, 'command') + .callsFake(async function (ns, cmd, options, responseType) { + // @ts-expect-error: sinon will place wrappedMethod on the command method. + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + if (!initialClientMetadata) { + initialClientMetadata = cmd.client; + } else { + updatedClientMetadata = cmd.client; + } + } + return command(ns, cmd, options, responseType); + }); + + await client.db('test').command({ ping: 1 }); + + // 5. Wait 5ms for the connection to become idle. + await sleep(5); + + // 6. Append the `DriverInfoOptions` from the selected test case from the appended metadata section. + client.appendMetadata(metadata.appended); + + // 7. Send a `ping` command to the server and verify the command succeeds. + await client.db('test').command({ ping: 1 }); + + // 9. Assert that `initialClientMetadata` is identical to `updatedClientMetadata`. + expect(updatedClientMetadata).to.deep.equal(initialClientMetadata); + }); + }); + } + }); + + describe('Test 8: Empty strings are considered unset when appending metadata identical to initial metadata', function () { + let initialClientMetadata: ClientMetadata; + let updatedClientMetadata: ClientMetadata; + // TODO(NODE-6599): mongodb-legacy adds additional client metadata, breaking these prose tests + let client: RawMongoClient; + + afterEach(async function () { + await client.close(); + initialClientMetadata = undefined; + updatedClientMetadata = undefined; + }); + + const driverInfos: Array<{ + initial: DriverInfo; + appended: DriverInfo; + }> = [ + { + initial: { + name: undefined, + version: '1.2', + platform: 'Library Platform' + }, + appended: { + name: '', + version: '1.2', + platform: 'Library Platform' + } + }, + { + initial: { + name: 'library', + version: undefined, + platform: 'Library Platform' + }, + appended: { + name: 'library', + version: '', + platform: 'Library Platform' + } + }, + { + initial: { + name: 'library', + version: '1.2', + platform: undefined + }, + appended: { + name: 'library', + version: '1.2', + platform: '' + } + } + ]; + + for (const [metadata, index] of driverInfos.map((infos, i) => [infos, i] as const)) { + describe(`Test ${index + 1}`, function () { + it('does not appended duplicate metadata', async function () { + // 1. Create a `MongoClient` instance with: + // - `maxIdleTimeMS` set to `1ms` + // - `driverInfo` set to the `DriverInfoOptions` from the selected test case from the initial metadata section. + client = new RawMongoClient(this.configuration.url(), { + maxIdleTimeMS: 1, + serverApi: this.configuration.serverApi, + driverInfo: metadata.initial + }); + + // 2. Send a `ping` command to the server and verify that the command succeeds. + // 3. Save intercepted `client` document as `initialClientMetadata`. + // 7. Store the response as `updatedClientMetadata`. + sinon + .stub(Connection.prototype, 'command') + .callsFake(async function (ns, cmd, options, responseType) { + // @ts-expect-error: sinon will place wrappedMethod on the command method. + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + if (!initialClientMetadata) { + initialClientMetadata = cmd.client; + } else { + updatedClientMetadata = cmd.client; + } + } + return command(ns, cmd, options, responseType); + }); + + await client.db('test').command({ ping: 1 }); + + // 4. Wait 5ms for the connection to become idle. + await sleep(5); + + // 5. Append the `DriverInfoOptions` from the selected test case from the appended metadata section. + client.appendMetadata(metadata.appended); + + // 6. Send a `ping` command to the server and verify the command succeeds. + await client.db('test').command({ ping: 1 }); + + // 8. Assert that `initialClientMetadata` is identical to `updatedClientMetadata`. + expect(updatedClientMetadata).to.deep.equal(initialClientMetadata); + }); + }); + } + }); }); diff --git a/test/tools/runner/filters/api_version_filter.ts b/test/tools/runner/filters/api_version_filter.ts index dcc8b345916..0c4b376ef17 100755 --- a/test/tools/runner/filters/api_version_filter.ts +++ b/test/tools/runner/filters/api_version_filter.ts @@ -1,3 +1,4 @@ +import { type MongoClient } from '../../../mongodb'; import { Filter } from './filter'; /** @@ -17,7 +18,18 @@ export class ApiVersionFilter extends Filter { constructor() { super(); // Get environmental variables that are known - this.apiVersion = process.env.MONGODB_API_VERSION; + this.apiVersion = + typeof process.env.MONGODB_API_VERSION === 'string' && + process.env.MONGODB_API_VERSION.length > 0 + ? process.env.MONGODB_API_VERSION + : undefined; + } + + override async initializeFilter( + _client: MongoClient, + context: Record + ): Promise { + context.serverApi = this.apiVersion; } filter(test: { metadata?: MongoDBMetadataUI }) { diff --git a/test/tools/runner/hooks/configuration.ts b/test/tools/runner/hooks/configuration.ts index 1e20f558cad..4f9522e5222 100644 --- a/test/tools/runner/hooks/configuration.ts +++ b/test/tools/runner/hooks/configuration.ts @@ -40,7 +40,6 @@ process.env.MONGODB_URI = // determine the connection string based on the value of process.env.AUTH const MONGODB_URI = process.env.MONGODB_URI; -const MONGODB_API_VERSION = process.env.MONGODB_API_VERSION; // Load balancer fronting 1 mongos. const SINGLE_MONGOS_LB_URI = process.env.SINGLE_MONGOS_LB_URI; // Load balancer fronting 2 mongoses. @@ -126,10 +125,6 @@ const testConfigBeforeHook = async function () { const context = await initializeFilters(client); - if (MONGODB_API_VERSION) { - context.serverApi = MONGODB_API_VERSION; - } - if (SINGLE_MONGOS_LB_URI && MULTI_MONGOS_LB_URI) { context.singleMongosLoadBalancerUri = SINGLE_MONGOS_LB_URI; context.multiMongosLoadBalancerUri = MULTI_MONGOS_LB_URI; @@ -167,7 +162,7 @@ const testConfigBeforeHook = async function () { }, cryptSharedVersion: context.cryptSharedVersion, cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH, - serverApi: MONGODB_API_VERSION, + serverApi: process.env.MONGODB_API_VERSION, atlas: process.env.ATLAS_CONNECTIVITY != null, aws: MONGODB_URI.includes('authMechanism=MONGODB-AWS'), awsSdk: process.env.MONGODB_AWS_SDK, diff --git a/test/unit/cmap/handshake/client_metadata.test.ts b/test/unit/cmap/handshake/client_metadata.test.ts index 8829c9ef981..0ca413f4fdf 100644 --- a/test/unit/cmap/handshake/client_metadata.test.ts +++ b/test/unit/cmap/handshake/client_metadata.test.ts @@ -142,7 +142,7 @@ describe('client metadata module', () => { describe('makeClientMetadata()', () => { context('when no FAAS environment is detected', () => { it('does not append FAAS metadata', () => { - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata).not.to.have.property( 'env', 'faas metadata applied in a non-faas environment' @@ -165,20 +165,14 @@ describe('client metadata module', () => { context('when driverInfo.platform is provided', () => { it('throws an error if driverInfo.platform is too large', () => { - expect(() => - makeClientMetadata({ - driverInfo: { platform: 'a'.repeat(512) }, - additionalDriverInfo: [] - }) - ).to.throw(MongoInvalidArgumentError, /platform/); + expect(() => makeClientMetadata([{ platform: 'a'.repeat(512) }], {})).to.throw( + MongoInvalidArgumentError, + /platform/ + ); }); it('appends driverInfo.platform to the platform field', () => { - const options = { - driverInfo: { platform: 'myPlatform' }, - additionalDriverInfo: [] - }; - const metadata = makeClientMetadata(options); + const metadata = makeClientMetadata([{ platform: 'myPlatform' }], {}); expect(metadata).to.deep.equal({ driver: { name: 'nodejs', @@ -197,17 +191,14 @@ describe('client metadata module', () => { context('when driverInfo.name is provided', () => { it('throws an error if driverInfo.name is too large', () => { - expect(() => - makeClientMetadata({ driverInfo: { name: 'a'.repeat(512) }, additionalDriverInfo: [] }) - ).to.throw(MongoInvalidArgumentError, /name/); + expect(() => makeClientMetadata([{ name: 'a'.repeat(512) }], {})).to.throw( + MongoInvalidArgumentError, + /name/ + ); }); it('appends driverInfo.name to the driver.name field', () => { - const options = { - driverInfo: { name: 'myName' }, - additionalDriverInfo: [] - }; - const metadata = makeClientMetadata(options); + const metadata = makeClientMetadata([{ name: 'myName' }], {}); expect(metadata).to.deep.equal({ driver: { name: 'nodejs|myName', @@ -226,17 +217,14 @@ describe('client metadata module', () => { context('when driverInfo.version is provided', () => { it('throws an error if driverInfo.version is too large', () => { - expect(() => - makeClientMetadata({ driverInfo: { version: 'a'.repeat(512) }, additionalDriverInfo: [] }) - ).to.throw(MongoInvalidArgumentError, /version/); + expect(() => makeClientMetadata([{ version: 'a'.repeat(512) }], {})).to.throw( + MongoInvalidArgumentError, + /version/ + ); }); it('appends driverInfo.version to the version field', () => { - const options = { - driverInfo: { version: 'myVersion' }, - additionalDriverInfo: [] - }; - const metadata = makeClientMetadata(options); + const metadata = makeClientMetadata([{ version: 'myVersion' }], {}); expect(metadata).to.deep.equal({ driver: { name: 'nodejs', @@ -254,7 +242,7 @@ describe('client metadata module', () => { }); context('when no custom driverInto is provided', () => { - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); it('does not append the driver info to the metadata', () => { expect(metadata).to.deep.equal({ @@ -280,12 +268,9 @@ describe('client metadata module', () => { context('when app name is provided', () => { context('when the app name is over 128 bytes', () => { const longString = 'a'.repeat(300); - const options = { - appName: longString, - driverInfo: {}, - additionalDriverInfo: [] - }; - const metadata = makeClientMetadata(options); + const metadata = makeClientMetadata([], { + appName: longString + }); it('truncates the application name to <=128 bytes', () => { expect(metadata.application?.name).to.be.a('string'); @@ -300,12 +285,9 @@ describe('client metadata module', () => { 'TODO(NODE-5150): fix appName truncation when multi-byte unicode charaters straddle byte 128', () => { const longString = '€'.repeat(300); - const options = { - appName: longString, - driverInfo: {}, - additionalDriverInfo: [] - }; - const metadata = makeClientMetadata(options); + const metadata = makeClientMetadata([], { + appName: longString + }); it('truncates the application name to 129 bytes', () => { expect(metadata.application?.name).to.be.a('string'); @@ -318,12 +300,9 @@ describe('client metadata module', () => { ); context('when the app name is under 128 bytes', () => { - const options = { - appName: 'myApplication', - driverInfo: {}, - additionalDriverInfo: [] - }; - const metadata = makeClientMetadata(options); + const metadata = makeClientMetadata([], { + appName: 'myApplication' + }); it('sets the application name to the value', () => { expect(metadata.application?.name).to.equal('myApplication'); @@ -339,40 +318,37 @@ describe('client metadata module', () => { it('sets platform to Deno', () => { globalThis.Deno = { version: { deno: '1.2.3' } }; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Deno v1.2.3, LE'); }); it('sets platform to Deno with driverInfo.platform', () => { globalThis.Deno = { version: { deno: '1.2.3' } }; - const metadata = makeClientMetadata({ - driverInfo: { platform: 'myPlatform' }, - additionalDriverInfo: [] - }); + const metadata = makeClientMetadata([{ platform: 'myPlatform' }], {}); expect(metadata.platform).to.equal('Deno v1.2.3, LE|myPlatform'); }); it('ignores version if Deno.version.deno is not a string', () => { globalThis.Deno = { version: { deno: 1 } }; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Deno v0.0.0-unknown, LE'); }); it('ignores version if Deno.version does not have a deno property', () => { globalThis.Deno = { version: { somethingElse: '1.2.3' } }; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Deno v0.0.0-unknown, LE'); }); it('ignores version if Deno.version is null', () => { globalThis.Deno = { version: null }; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Deno v0.0.0-unknown, LE'); }); it('ignores version if Deno is nullish', () => { globalThis.Deno = null; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Deno v0.0.0-unknown, LE'); }); }); @@ -386,7 +362,7 @@ describe('client metadata module', () => { globalThis.Bun = class { static version = '1.2.3'; }; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Bun v1.2.3, LE'); }); @@ -394,10 +370,7 @@ describe('client metadata module', () => { globalThis.Bun = class { static version = '1.2.3'; }; - const metadata = makeClientMetadata({ - driverInfo: { platform: 'myPlatform' }, - additionalDriverInfo: [] - }); + const metadata = makeClientMetadata([{ platform: 'myPlatform' }], {}); expect(metadata.platform).to.equal('Bun v1.2.3, LE|myPlatform'); }); @@ -405,7 +378,7 @@ describe('client metadata module', () => { globalThis.Bun = class { static version = 1; }; - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata.platform).to.equal('Bun v0.0.0-unknown, LE'); }); @@ -413,19 +386,13 @@ describe('client metadata module', () => { globalThis.Bun = class { static version = 1; }; - const metadata = makeClientMetadata({ - driverInfo: { platform: 'myPlatform' }, - additionalDriverInfo: [] - }); + const metadata = makeClientMetadata([{ platform: 'myPlatform' }], {}); expect(metadata.platform).to.equal('Bun v0.0.0-unknown, LE|myPlatform'); }); it('ignores version if Bun is nullish', () => { globalThis.Bun = null; - const metadata = makeClientMetadata({ - driverInfo: { platform: 'myPlatform' }, - additionalDriverInfo: [] - }); + const metadata = makeClientMetadata([{ platform: 'myPlatform' }], {}); expect(metadata.platform).to.equal('Bun v0.0.0-unknown, LE|myPlatform'); }); }); @@ -546,7 +513,7 @@ describe('client metadata module', () => { }); it(`returns ${inspect(outcome)} under env property`, () => { - const { env } = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const { env } = makeClientMetadata([], {}); expect(env).to.deep.equal(outcome); }); @@ -570,9 +537,7 @@ describe('client metadata module', () => { }); it('does not attach it to the metadata', () => { - expect( - makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }) - ).not.to.have.nested.property('aws.memory_mb'); + expect(makeClientMetadata([], {})).not.to.have.nested.property('aws.memory_mb'); }); }); }); @@ -587,7 +552,7 @@ describe('client metadata module', () => { }); it('only includes env.name', () => { - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata).to.not.have.nested.property('env.region'); expect(metadata).to.have.nested.property('env.name', 'aws.lambda'); expect(metadata.env).to.have.all.keys('name'); @@ -605,7 +570,7 @@ describe('client metadata module', () => { }); it('only includes env.name', () => { - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata).to.have.property('env'); expect(metadata).to.have.nested.property('env.region', 'abc'); expect(metadata.os).to.have.all.keys('type'); @@ -622,7 +587,7 @@ describe('client metadata module', () => { }); it('omits os information', () => { - const metadata = makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }); + const metadata = makeClientMetadata([], {}); expect(metadata).to.not.have.property('os'); }); }); @@ -638,10 +603,7 @@ describe('client metadata module', () => { }); it('omits the faas env', () => { - const metadata = makeClientMetadata({ - driverInfo: { name: 'a'.repeat(350) }, - additionalDriverInfo: [] - }); + const metadata = makeClientMetadata([{ name: 'a'.repeat(350) }], {}); expect(metadata).to.not.have.property('env'); }); }); diff --git a/test/unit/sdam/topology.test.ts b/test/unit/sdam/topology.test.ts index 60c4dcf2def..444ff7d3e86 100644 --- a/test/unit/sdam/topology.test.ts +++ b/test/unit/sdam/topology.test.ts @@ -53,10 +53,8 @@ describe('Topology (unit)', function () { it('should correctly pass appname', function () { const server: Topology = topologyWithPlaceholderClient([`localhost:27017`], { - metadata: makeClientMetadata({ - appName: 'My application name', - driverInfo: {}, - additionalDriverInfo: [] + metadata: makeClientMetadata([], { + appName: 'My application name' }) });