diff --git a/.eslintignore b/.eslintignore index 741a987a65..e44ff16bca 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ docs lib +test/disabled diff --git a/package-lock.json b/package-lock.json index 636185c380..369a34c430 100644 --- a/package-lock.json +++ b/package-lock.json @@ -566,6 +566,12 @@ "integrity": "sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=", "dev": true }, + "@types/mocha": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-8.2.0.tgz", + "integrity": "sha512-/Sge3BymXo4lKc31C8OINJgXLaw+7vL1/L1pGiBNpGrBiT8FQiaFpSYV0uhTaG4y78vcMBTMFsWaHDvuD+xGzQ==", + "dev": true + }, "@types/node": { "version": "14.6.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.4.tgz", diff --git a/package.json b/package.json index 7038aa58d8..3d88bc02d7 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/bl": "^2.1.0", "@types/bson": "^4.0.2", "@types/kerberos": "^1.1.0", + "@types/mocha": "^8.2.0", "@types/node": "^14.6.4", "@types/saslprep": "^1.0.0", "@typescript-eslint/eslint-plugin": "^3.10.0", diff --git a/src/bson.ts b/src/bson.ts index 628612d40d..c7b8fa6274 100644 --- a/src/bson.ts +++ b/src/bson.ts @@ -37,7 +37,7 @@ import type { SerializeOptions } from 'bson'; * BSON Serialization options. * @public */ -export interface BSONSerializeOptions extends SerializeOptions { +export interface BSONSerializeOptions extends Omit { /** Return document results as raw BSON buffers */ fieldsAsRaw?: { [key: string]: boolean }; /** Promotes BSON values to native types where possible, set to false to only receive wrapper types */ diff --git a/src/cmap/auth/defaultAuthProviders.ts b/src/cmap/auth/defaultAuthProviders.ts index 0d9d77321c..353704fc2b 100644 --- a/src/cmap/auth/defaultAuthProviders.ts +++ b/src/cmap/auth/defaultAuthProviders.ts @@ -21,20 +21,12 @@ export const AuthMechanism = { /** @public */ export type AuthMechanismId = typeof AuthMechanism[keyof typeof AuthMechanism]; -export const AUTH_PROVIDERS = { - [AuthMechanism.MONGODB_AWS]: new MongoDBAWS(), - [AuthMechanism.MONGODB_CR]: new MongoCR(), - [AuthMechanism.MONGODB_GSSAPI]: new GSSAPI(), - [AuthMechanism.MONGODB_PLAIN]: new Plain(), - [AuthMechanism.MONGODB_SCRAM_SHA1]: new ScramSHA1(), - [AuthMechanism.MONGODB_SCRAM_SHA256]: new ScramSHA256(), - [AuthMechanism.MONGODB_X509]: new X509() -}; - -// TODO: We can make auth mechanism more functional since we pass around a context object -// and we improve the the typing here to use the enum, the current issue is that the mechanism is -// 'default' until resolved maybe we can do that resolution here and make the this strictly -// AuthMechanism -> AuthProviders -export function defaultAuthProviders(): Record { - return AUTH_PROVIDERS; -} +export const AUTH_PROVIDERS = new Map([ + [AuthMechanism.MONGODB_AWS, new MongoDBAWS()], + [AuthMechanism.MONGODB_CR, new MongoCR()], + [AuthMechanism.MONGODB_GSSAPI, new GSSAPI()], + [AuthMechanism.MONGODB_PLAIN, new Plain()], + [AuthMechanism.MONGODB_SCRAM_SHA1, new ScramSHA1()], + [AuthMechanism.MONGODB_SCRAM_SHA256, new ScramSHA256()], + [AuthMechanism.MONGODB_X509, new X509()] +]); diff --git a/src/cmap/auth/gssapi.ts b/src/cmap/auth/gssapi.ts index 61d05ebc37..e86042941f 100644 --- a/src/cmap/auth/gssapi.ts +++ b/src/cmap/auth/gssapi.ts @@ -60,16 +60,10 @@ export class GSSAPI extends AuthProvider { } } function makeKerberosClient(authContext: AuthContext, callback: Callback): void { - const { host, port } = authContext.options; + const { hostAddress } = authContext.options; const { credentials } = authContext; - if (!host || !port || !credentials) { - return callback( - new MongoError( - `Connection must specify: ${host ? 'host' : ''}, ${port ? 'port' : ''}, ${ - credentials ? 'host' : 'credentials' - }.` - ) - ); + if (!hostAddress || typeof hostAddress.host !== 'string' || !credentials) { + return callback(new MongoError('Connection must have host and port and credentials defined.')); } if ('kModuleError' in Kerberos) { @@ -83,7 +77,7 @@ function makeKerberosClient(authContext: AuthContext, callback: Callback { if (err) return callback(err); diff --git a/src/cmap/auth/mongo_credentials.ts b/src/cmap/auth/mongo_credentials.ts index 748a19b1fd..800707c6ac 100644 --- a/src/cmap/auth/mongo_credentials.ts +++ b/src/cmap/auth/mongo_credentials.ts @@ -147,15 +147,15 @@ export class MongoCredentials { } static merge( - creds: MongoCredentials, + creds: MongoCredentials | undefined, options: Partial ): MongoCredentials { return new MongoCredentials({ - username: options.username ?? creds.username, - password: options.password ?? creds.password, - mechanism: options.mechanism ?? creds.mechanism, - mechanismProperties: options.mechanismProperties ?? creds.mechanismProperties, - source: options.source ?? creds.source ?? options.db + username: options.username ?? creds?.username ?? '', + password: options.password ?? creds?.password ?? '', + mechanism: options.mechanism ?? creds?.mechanism ?? AuthMechanism.MONGODB_DEFAULT, + mechanismProperties: options.mechanismProperties ?? creds?.mechanismProperties ?? {}, + source: options.source ?? options.db ?? creds?.source ?? 'admin' }); } } diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index d10af047e5..5da2303ec4 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -45,7 +45,7 @@ export class MongoDBAWS extends AuthProvider { if (credentials.username == null) { makeTempCredentials(credentials, (err, tempCredentials) => { - if (err) return callback(err); + if (err || !tempCredentials) return callback(err); authContext.credentials = tempCredentials; this.auth(authContext, callback); diff --git a/src/cmap/connect.ts b/src/cmap/connect.ts index 4d3fcfb9b2..0d324c5418 100644 --- a/src/cmap/connect.ts +++ b/src/cmap/connect.ts @@ -2,7 +2,7 @@ import * as net from 'net'; import * as tls from 'tls'; import { Connection, ConnectionOptions, CryptoConnection } from './connection'; import { MongoError, MongoNetworkError, MongoNetworkTimeoutError, AnyError } from '../error'; -import { defaultAuthProviders, AuthMechanism } from './auth/defaultAuthProviders'; +import { AUTH_PROVIDERS, AuthMechanism } from './auth/defaultAuthProviders'; import { AuthContext } from './auth/auth_provider'; import { makeClientMetadata, ClientMetadata, Callback, CallbackWithType, ns } from '../utils'; import { @@ -12,46 +12,24 @@ import { MIN_SUPPORTED_SERVER_VERSION } from './wire_protocol/constants'; import type { Document } from '../bson'; -import type { EventEmitter } from 'events'; import type { Socket, SocketConnectOpts } from 'net'; import type { TLSSocket, ConnectionOptions as TLSConnectionOpts } from 'tls'; +import { Int32 } from '../bson'; /** @public */ export type Stream = Socket | TLSSocket; -const AUTH_PROVIDERS = defaultAuthProviders(); - -export function connect(options: ConnectionOptions, callback: Callback): void; -export function connect( - options: ConnectionOptions, - cancellationToken: EventEmitter, - callback: Callback -): void; -export function connect( - options: ConnectionOptions, - _cancellationToken: EventEmitter | Callback, - _callback?: Callback -): void { - let cancellationToken = _cancellationToken as EventEmitter | undefined; - const callback = (_callback ?? _cancellationToken) as Callback; - if ('function' === typeof cancellationToken) { - cancellationToken = undefined; - } - - const family = options.family !== undefined ? options.family : 0; - let ConnectionType: typeof Connection = - options && options.connectionType ? options.connectionType : Connection; - if (options.autoEncrypter) { - ConnectionType = CryptoConnection; - } - - makeConnection(family, options, cancellationToken, (err, socket) => { +export function connect(options: ConnectionOptions, callback: Callback): void { + makeConnection(options, (err, socket) => { if (err || !socket) { - callback(err); - return; + return callback(err); } + let ConnectionType = options.connectionType ?? Connection; + if (options.autoEncrypter) { + ConnectionType = CryptoConnection; + } performInitialHandshake(new ConnectionType(socket, options), options, callback); }); } @@ -59,11 +37,11 @@ export function connect( function checkSupportedServer(ismaster: Document, options: ConnectionOptions) { const serverVersionHighEnough = ismaster && - typeof ismaster.maxWireVersion === 'number' && + (typeof ismaster.maxWireVersion === 'number' || ismaster.maxWireVersion instanceof Int32) && ismaster.maxWireVersion >= MIN_SUPPORTED_WIRE_VERSION; const serverVersionLowEnough = ismaster && - typeof ismaster.minWireVersion === 'number' && + (typeof ismaster.maxWireVersion === 'number' || ismaster.maxWireVersion instanceof Int32) && ismaster.minWireVersion <= MAX_SUPPORTED_WIRE_VERSION; if (serverVersionHighEnough) { @@ -71,12 +49,14 @@ function checkSupportedServer(ismaster: Document, options: ConnectionOptions) { return null; } - const message = `Server at ${options.host}:${options.port} reports minimum wire version ${ismaster.minWireVersion}, but this version of the Node.js Driver requires at most ${MAX_SUPPORTED_WIRE_VERSION} (MongoDB ${MAX_SUPPORTED_SERVER_VERSION})`; + const message = `Server at ${options.hostAddress} reports minimum wire version ${JSON.stringify( + ismaster.minWireVersion + )}, but this version of the Node.js Driver requires at most ${MAX_SUPPORTED_WIRE_VERSION} (MongoDB ${MAX_SUPPORTED_SERVER_VERSION})`; return new MongoError(message); } - const message = `Server at ${options.host}:${options.port} reports maximum wire version ${ - ismaster.maxWireVersion || 0 + const message = `Server at ${options.hostAddress} reports maximum wire version ${ + JSON.stringify(ismaster.maxWireVersion) ?? 0 }, but this version of the Node.js Driver requires at least ${MIN_SUPPORTED_WIRE_VERSION} (MongoDB ${MIN_SUPPORTED_SERVER_VERSION})`; return new MongoError(message); } @@ -95,7 +75,10 @@ function performInitialHandshake( const credentials = options.credentials; if (credentials) { - if (!credentials.mechanism.match(/DEFAULT/i) && !AUTH_PROVIDERS[credentials.mechanism]) { + if ( + !(credentials.mechanism === AuthMechanism.MONGODB_DEFAULT) && + !AUTH_PROVIDERS.get(credentials.mechanism) + ) { callback(new MongoError(`authMechanism '${credentials.mechanism}' not supported`)); return; } @@ -108,9 +91,9 @@ function performInitialHandshake( } const handshakeOptions: Document = Object.assign({}, options); - if (options.connectTimeoutMS || options.connectionTimeout) { + if (typeof options.connectTimeoutMS === 'number') { // The handshake technically is a monitoring check, so its socket timeout should be connectTimeoutMS - handshakeOptions.socketTimeout = options.connectTimeoutMS || options.connectionTimeout; + handshakeOptions.socketTimeout = options.connectTimeoutMS; } const start = new Date().getTime(); @@ -142,7 +125,12 @@ function performInitialHandshake( authContext.response = response; const resolvedCredentials = credentials.resolveAuthMechanism(response); - const provider = AUTH_PROVIDERS[resolvedCredentials.mechanism]; + const provider = AUTH_PROVIDERS.get(resolvedCredentials.mechanism); + if (!provider) { + return callback( + new MongoError(`No AuthProvider for ${resolvedCredentials.mechanism} defined.`) + ); + } provider.auth(authContext, err => { if (err) return callback(err); callback(undefined, conn); @@ -165,10 +153,9 @@ export interface HandshakeDocument extends Document { function prepareHandshakeDocument(authContext: AuthContext, callback: Callback) { const options = authContext.options; - const compressors = - options.compression && options.compression.compressors ? options.compression.compressors : []; + const compressors = options.compressors ? options.compressors : []; - const handshakeDoc = { + const handshakeDoc: HandshakeDocument = { ismaster: true, client: options.metadata || makeClientMetadata(options), compression: compressors @@ -176,69 +163,87 @@ function prepareHandshakeDocument(authContext: AuthContext, callback: Callback = {}; + for (const name of LEGAL_TCP_SOCKET_OPTIONS) { + if (options[name] != null) { + (result as Document)[name] = options[name]; + } + } - return result; + if (typeof hostAddress.socketPath === 'string') { + result.path = hostAddress.socketPath; + return result as net.IpcNetConnectOpts; + } else if (typeof hostAddress.host === 'string') { + result.host = hostAddress.host; + result.port = hostAddress.port; + return result as net.TcpNetConnectOpts; + } else { + // This should never happen since we set up HostAddresses + // But if we don't throw here the socket could hang until timeout + throw new Error(`Unexpected HostAddress ${JSON.stringify(hostAddress)}`); + } } -function parseSslOptions(family: number, options: ConnectionOptions): TLSConnectionOpts { - const result: TLSConnectionOpts = parseConnectOptions(family, options); +function parseSslOptions(options: ConnectionOptions): TLSConnectionOpts { + const result: TLSConnectionOpts = parseConnectOptions(options); // Merge in valid SSL options - for (const name of LEGAL_SSL_SOCKET_OPTIONS) { - if (options[name]) { - (result as { [k: string]: unknown })[name] = options[name]; + for (const name of LEGAL_TLS_SOCKET_OPTIONS) { + if (options[name] != null) { + (result as Document)[name] = options[name]; } } @@ -263,30 +268,17 @@ const SOCKET_ERROR_EVENT_LIST = ['error', 'close', 'timeout', 'parseError'] as c type ErrorHandlerEventName = typeof SOCKET_ERROR_EVENT_LIST[number] | 'cancel'; const SOCKET_ERROR_EVENTS = new Set(SOCKET_ERROR_EVENT_LIST); -function makeConnection( - family: number, - options: ConnectionOptions, - cancellationToken: EventEmitter | undefined, - _callback: CallbackWithType -) { - const useSsl = typeof options.ssl === 'boolean' ? options.ssl : false; - const keepAlive = typeof options.keepAlive === 'boolean' ? options.keepAlive : true; - let keepAliveInitialDelay = - typeof options.keepAliveInitialDelay === 'number' ? options.keepAliveInitialDelay : 120000; - const noDelay = typeof options.noDelay === 'boolean' ? options.noDelay : true; - const connectionTimeout = - typeof options.connectionTimeout === 'number' - ? options.connectionTimeout - : typeof options.connectTimeoutMS === 'number' - ? options.connectTimeoutMS - : 30000; - const socketTimeout = typeof options.socketTimeout === 'number' ? options.socketTimeout : 0; - const rejectUnauthorized = - typeof options.rejectUnauthorized === 'boolean' ? options.rejectUnauthorized : true; - - if (keepAliveInitialDelay > socketTimeout) { - keepAliveInitialDelay = Math.round(socketTimeout / 2); - } +function makeConnection(options: ConnectionOptions, _callback: CallbackWithType) { + const useTLS = options.tls ?? false; + const keepAlive = options.keepAlive ?? true; + const socketTimeout = options.socketTimeout ?? 0; + const noDelay = options.noDelay ?? true; + const connectionTimeout = options.connectTimeoutMS ?? 30000; + const rejectUnauthorized = options.rejectUnauthorized ?? true; + const keepAliveInitialDelay = + ((options.keepAliveInitialDelay ?? 120000) > socketTimeout + ? Math.round(socketTimeout / 2) + : options.keepAliveInitialDelay) ?? 120000; let socket: Stream; const callback: Callback = function (err, ret) { @@ -298,14 +290,14 @@ function makeConnection( }; try { - if (useSsl) { - const tlsSocket = tls.connect(parseSslOptions(family, options)); + if (useTLS) { + const tlsSocket = tls.connect(parseSslOptions(options)); if (typeof tlsSocket.disableRenegotiation === 'function') { tlsSocket.disableRenegotiation(); } socket = tlsSocket; } else { - socket = net.createConnection(parseConnectOptions(family, options)); + socket = net.createConnection(parseConnectOptions(options)); } } catch (err) { return callback(err); @@ -315,13 +307,13 @@ function makeConnection( socket.setTimeout(connectionTimeout); socket.setNoDelay(noDelay); - const connectEvent = useSsl ? 'secureConnect' : 'connect'; + const connectEvent = useTLS ? 'secureConnect' : 'connect'; let cancellationHandler: (err: Error) => void; function errorHandler(eventName: ErrorHandlerEventName) { return (err: Error) => { SOCKET_ERROR_EVENTS.forEach(event => socket.removeAllListeners(event)); - if (cancellationHandler && cancellationToken) { - cancellationToken.removeListener('cancel', cancellationHandler); + if (cancellationHandler && options.cancellationToken) { + options.cancellationToken.removeListener('cancel', cancellationHandler); } socket.removeListener(connectEvent, connectHandler); @@ -331,8 +323,8 @@ function makeConnection( function connectHandler() { SOCKET_ERROR_EVENTS.forEach(event => socket.removeAllListeners(event)); - if (cancellationHandler && cancellationToken) { - cancellationToken.removeListener('cancel', cancellationHandler); + if (cancellationHandler && options.cancellationToken) { + options.cancellationToken.removeListener('cancel', cancellationHandler); } if ('authorizationError' in socket) { @@ -346,9 +338,9 @@ function makeConnection( } SOCKET_ERROR_EVENTS.forEach(event => socket.once(event, errorHandler(event))); - if (cancellationToken) { + if (options.cancellationToken) { cancellationHandler = errorHandler('cancel'); - cancellationToken.once('cancel', cancellationHandler); + options.cancellationToken.once('cancel', cancellationHandler); } socket.once(connectEvent, connectHandler); diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 0f085060f5..98aa78439c 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -10,7 +10,8 @@ import { calculateDurationInMs, Callback, MongoDBNamespace, - maxWireVersion + maxWireVersion, + HostAddress } from '../utils'; import { AnyError, @@ -31,15 +32,13 @@ import { } from './commands'; import { BSONSerializeOptions, Document, Long, pluckBSONSerializeOptions } from '../bson'; import type { AutoEncrypter } from '../deps'; -import type { ConnectionOptions as TLSConnectionOptions } from 'tls'; -import type { TcpNetConnectOpts, IpcNetConnectOpts } from 'net'; import type { MongoCredentials } from './auth/mongo_credentials'; import type { Stream } from './connect'; -import type { LoggerOptions } from '../logger'; import { applyCommonQueryOptions, getReadPreference, isSharded } from './wire_protocol/shared'; import { ReadPreference, ReadPreferenceLike } from '../read_preference'; import { isTransactionCommand } from '../transactions'; import type { W, WriteConcern, WriteConcernOptions } from '../write_concern'; +import type { SupportedNodeConnectionOptions } from '../mongo_client'; const kStream = Symbol('stream'); const kQueue = Symbol('queue'); @@ -100,28 +99,26 @@ export interface GetMoreOptions extends CommandOptions { /** @public */ export interface ConnectionOptions - extends Partial, - Partial, - Partial, - StreamDescriptionOptions, - LoggerOptions { - id: number; - monitorCommands: boolean; + extends SupportedNodeConnectionOptions, + StreamDescriptionOptions { + // Internal creation info + id: number | ''; generation: number; + hostAddress: HostAddress; + // Settings autoEncrypter?: AutoEncrypter; + monitorCommands: boolean; connectionType: typeof Connection; credentials?: MongoCredentials; connectTimeoutMS?: number; - connectionTimeout?: number; - ssl: boolean; + tls: boolean; keepAlive?: boolean; keepAliveInitialDelay?: number; noDelay?: boolean; socketTimeout?: number; + cancellationToken?: EventEmitter; metadata: ClientMetadata; - /** Required EventEmitter option */ - captureRejections?: boolean; } /** @public */ @@ -132,7 +129,7 @@ export interface DestroyOptions { /** @public */ export class Connection extends EventEmitter { - id: number; + id: number | ''; address: string; socketTimeout: number; monitorCommands: boolean; @@ -166,11 +163,11 @@ export class Connection extends EventEmitter { static readonly CLUSTER_TIME_RECEIVED = 'clusterTimeReceived' as const; constructor(stream: Stream, options: ConnectionOptions) { - super(options); + super(); this.id = options.id; this.address = streamIdentifier(stream); this.socketTimeout = options.socketTimeout ?? 0; - this.monitorCommands = options.monitorCommands ?? options.monitorCommands; + this.monitorCommands = options.monitorCommands; this.closed = false; this.destroyed = false; @@ -180,7 +177,10 @@ export class Connection extends EventEmitter { // setup parser stream and message handling this[kQueue] = new Map(); - this[kMessageStream] = new MessageStream(options); + this[kMessageStream] = new MessageStream({ + ...options, + maxBsonMessageSize: this.ismaster?.maxBsonMessageSize + }); this[kMessageStream].on('message', messageHandler(this)); this[kStream] = stream; stream.on('error', () => { @@ -590,7 +590,7 @@ export class CryptoConnection extends Connection { const serverWireVersion = maxWireVersion(this); if (serverWireVersion === 0) { - // This means the initial handshake hasn't happend yet + // This means the initial handshake hasn't happened yet return super.command(ns, cmd, options, callback); } diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 3d462d761d..a27dce5ca9 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -30,80 +30,8 @@ const kCancellationToken = Symbol('cancellationToken'); const kWaitQueue = Symbol('waitQueue'); const kCancelled = Symbol('cancelled'); -const VALID_POOL_OPTION_NAMES = [ - // `connect` options - 'ssl', - 'connectionType', - 'monitorCommands', - 'socketTimeout', - 'credentials', - 'compression', - - // node Net options - 'host', - 'port', - 'localAddress', - 'localPort', - 'family', - 'hints', - 'lookup', - 'path', - - // node TLS options - 'ca', - 'cert', - 'sigalgs', - 'ciphers', - 'clientCertEngine', - 'crl', - 'dhparam', - 'ecdhCurve', - 'honorCipherOrder', - 'key', - 'privateKeyEngine', - 'privateKeyIdentifier', - 'maxVersion', - 'minVersion', - 'passphrase', - 'pfx', - 'secureOptions', - 'secureProtocol', - 'sessionIdContext', - 'allowHalfOpen', - 'rejectUnauthorized', - 'pskCallback', - 'ALPNProtocols', - 'servername', - 'checkServerIdentity', - 'session', - 'minDHSize', - 'secureContext', - - // spec options - 'maxPoolSize', - 'minPoolSize', - 'maxIdleTimeMS', - 'waitQueueTimeoutMS' -] as const; - -const VALID_POOL_OPTIONS = new Set(VALID_POOL_OPTION_NAMES); - -function resolveOptions( - options: Partial, - defaults: Partial -): Readonly { - const newOptions = {}; - for (const key of VALID_POOL_OPTIONS) { - if (key in options) { - (newOptions as { [key: string]: unknown })[key] = options[key]; - } - } - - return Object.freeze(Object.assign({}, defaults, newOptions)) as ConnectionPoolOptions; -} - /** @public */ -export interface ConnectionPoolOptions extends ConnectionOptions { +export interface ConnectionPoolOptions extends Omit { /** The maximum number of connections that may be associated with a pool at a given time. This includes in use and available connections. */ maxPoolSize: number; /** The minimum number of connections that MUST exist at any moment in a single connection pool. */ @@ -207,11 +135,12 @@ export class ConnectionPool extends EventEmitter { */ static readonly CONNECTION_POOL_CLEARED = 'connectionPoolCleared' as const; - constructor(options: Partial) { + constructor(options: ConnectionPoolOptions) { super(); this.closed = false; - this.options = resolveOptions(options, { + this.options = Object.freeze({ + ...options, connectionType: Connection, maxPoolSize: options.maxPoolSize ?? 100, minPoolSize: options.minPoolSize ?? 0, @@ -227,7 +156,7 @@ export class ConnectionPool extends EventEmitter { ); } - this[kLogger] = new Logger('ConnectionPool', options); + this[kLogger] = new Logger('ConnectionPool'); this[kConnections] = new Denque(); this[kPermits] = this.options.maxPoolSize; this[kMinPoolSizeTimer] = undefined; @@ -245,7 +174,7 @@ export class ConnectionPool extends EventEmitter { /** The address of the endpoint the pool is connected to */ get address(): string { - return `${this.options.host}:${this.options.port}`; + return this.options.hostAddress.toString(); } /** An integer representing the SDAM generation of the pool */ @@ -482,16 +411,15 @@ function connectionIsIdle(pool: ConnectionPool, connection: Connection) { } function createConnection(pool: ConnectionPool, callback?: Callback) { - const connectOptions = Object.assign( - { - id: pool[kConnectionCounter].next().value, - generation: pool[kGeneration] - }, - pool.options - ); + const connectOptions: ConnectionOptions = { + ...pool.options, + id: pool[kConnectionCounter].next().value, + generation: pool[kGeneration], + cancellationToken: pool[kCancellationToken] + }; pool[kPermits]--; - connect(connectOptions, pool[kCancellationToken], (err, connection) => { + connect(connectOptions, (err, connection) => { if (err || !connection) { pool[kPermits]++; pool[kLogger].debug(`connection attempt failed with error [${JSON.stringify(err)}]`); diff --git a/src/cmap/events.ts b/src/cmap/events.ts index 2632061e51..084e4a91aa 100644 --- a/src/cmap/events.ts +++ b/src/cmap/events.ts @@ -51,7 +51,7 @@ export class ConnectionPoolClosedEvent extends ConnectionPoolMonitoringEvent { */ export class ConnectionCreatedEvent extends ConnectionPoolMonitoringEvent { /** A monotonically increasing, per-pool id for the newly created connection */ - connectionId: number; + connectionId: number | ''; constructor(pool: ConnectionPool, connection: Connection) { super(pool); @@ -65,7 +65,7 @@ export class ConnectionCreatedEvent extends ConnectionPoolMonitoringEvent { */ export class ConnectionReadyEvent extends ConnectionPoolMonitoringEvent { /** The id of the connection */ - connectionId: number; + connectionId: number | ''; constructor(pool: ConnectionPool, connection: Connection) { super(pool); @@ -79,7 +79,7 @@ export class ConnectionReadyEvent extends ConnectionPoolMonitoringEvent { */ export class ConnectionClosedEvent extends ConnectionPoolMonitoringEvent { /** The id of the connection */ - connectionId: number; + connectionId: number | ''; /** The reason the connection was closed */ reason: string; @@ -117,7 +117,7 @@ export class ConnectionCheckOutFailedEvent extends ConnectionPoolMonitoringEvent */ export class ConnectionCheckedOutEvent extends ConnectionPoolMonitoringEvent { /** The id of the connection */ - connectionId: number; + connectionId: number | ''; constructor(pool: ConnectionPool, connection: Connection) { super(pool); @@ -131,7 +131,7 @@ export class ConnectionCheckedOutEvent extends ConnectionPoolMonitoringEvent { */ export class ConnectionCheckedInEvent extends ConnectionPoolMonitoringEvent { /** The id of the connection */ - connectionId: number; + connectionId: number | ''; constructor(pool: ConnectionPool, connection: Connection) { super(pool); diff --git a/src/cmap/stream_description.ts b/src/cmap/stream_description.ts index 8209b51f3a..fe92cb218d 100644 --- a/src/cmap/stream_description.ts +++ b/src/cmap/stream_description.ts @@ -14,9 +14,7 @@ const RESPONSE_FIELDS = [ /** @public */ export interface StreamDescriptionOptions { - compression: { - compressors: CompressorName[]; - }; + compressors?: CompressorName[]; } /** @public */ @@ -32,7 +30,7 @@ export class StreamDescription { compressor?: CompressorName; logicalSessionTimeoutMinutes?: number; - __nodejs_mock_server__ = false; + __nodejs_mock_server__?: boolean; zlibCompressionLevel?: number; @@ -45,8 +43,8 @@ export class StreamDescription { this.maxMessageSizeBytes = 48000000; this.maxWriteBatchSize = 100000; this.compressors = - options && options.compression && Array.isArray(options.compression.compressors) - ? options.compression.compressors + options && options.compressors && Array.isArray(options.compressors) + ? options.compressors : []; } diff --git a/src/collection.ts b/src/collection.ts index 8105b3281f..eba96f9bd9 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -1,4 +1,4 @@ -import { emitDeprecatedOptionWarning, resolveOptions } from './utils'; +import { DEFAULT_PK_FACTORY, resolveOptions } from './utils'; import { ReadPreference, ReadPreferenceLike } from './read_preference'; import { deprecate } from 'util'; import { @@ -9,7 +9,7 @@ import { Callback, getTopology } from './utils'; -import { ObjectId, Document, BSONSerializeOptions, resolveBSONOptions } from './bson'; +import { Document, BSONSerializeOptions, resolveBSONOptions } from './bson'; import { MongoError } from './error'; import { UnorderedBulkOperation } from './bulk/unordered'; import { OrderedBulkOperation } from './bulk/ordered'; @@ -176,19 +176,13 @@ export class Collection { */ constructor(db: Db, name: string, options?: CollectionOptions) { checkCollectionName(name); - emitDeprecatedOptionWarning(options, ['promiseLibrary']); // Internal state this.s = { db, options, namespace: new MongoDBNamespace(db.databaseName, name), - pkFactory: db.options?.pkFactory ?? { - createPk() { - // We prefer not to rely on ObjectId having a createPk method - return new ObjectId(); - } - }, + pkFactory: db.options?.pkFactory ?? DEFAULT_PK_FACTORY, readPreference: ReadPreference.fromOptions(options), bsonOptions: resolveBSONOptions(options, db), readConcern: ReadConcern.fromOptions(options), diff --git a/src/connection_string.ts b/src/connection_string.ts index 66ecb97c69..ed3ef06429 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -1,19 +1,24 @@ -import * as url from 'url'; -import * as qs from 'querystring'; import * as dns from 'dns'; -import { URL } from 'url'; +import * as fs from 'fs'; +import { URL, URLSearchParams } from 'url'; import { AuthMechanism } from './cmap/auth/defaultAuthProviders'; import { ReadPreference, ReadPreferenceModeId } from './read_preference'; import { ReadConcern, ReadConcernLevelId } from './read_concern'; import { W, WriteConcern } from './write_concern'; import { MongoParseError } from './error'; -import { AnyOptions, Callback, isRecord } from './utils'; -import type { ConnectionOptions } from './cmap/connection'; +import { + AnyOptions, + Callback, + DEFAULT_PK_FACTORY, + isRecord, + makeClientMetadata, + setDifference, + HostAddress +} from './utils'; import type { Document } from './bson'; -import type { CompressorName } from './cmap/wire_protocol/compression'; -import type { +import { DriverInfo, - HostAddress, + MongoClient, MongoClientOptions, MongoOptions, PkFactory @@ -21,13 +26,8 @@ import type { import { MongoCredentials } from './cmap/auth/mongo_credentials'; import type { TagSet } from './sdam/server_description'; import { Logger, LoggerLevel } from './logger'; -import { ObjectId } from 'bson'; - -/** - * The following regular expression validates a connection string and breaks the - * provide string into the following capture groups: [protocol, username, password, hosts] - */ -const HOSTS_RX = /(mongodb(?:\+srv|)):\/\/(?: (?:[^:]*) (?: : ([^@]*) )? @ )?([^/?]*)(?:\/|)(.*)/; +import { PromiseProvider } from './promise_provider'; +import { createAutoEncrypter } from './operations/connect'; /** * Determines whether a provided address matches the provided parent domain in order @@ -51,28 +51,17 @@ function matchesParentDomain(srvAddress: string, parentDomain: string): boolean * @param uri - The connection string to parse * @param options - Optional user provided connection string options */ -function parseSrvConnectionString(uri: string, options: any, callback: Callback) { - const result: AnyOptions = url.parse(uri, true); - - if (options.directConnection) { - return callback(new MongoParseError('directConnection not supported with SRV URI')); +export function resolveSRVRecord(options: MongoOptions, callback: Callback): void { + if (typeof options.srvHost !== 'string') { + return callback(new MongoParseError('Cannot resolve empty srv string')); } - if (result.hostname.split('.').length < 3) { + if (options.srvHost.split('.').length < 3) { return callback(new MongoParseError('URI does not have hostname, domain name and tld')); } - result.domainLength = result.hostname.split('.').length; - if (result.pathname && result.pathname.match(',')) { - return callback(new MongoParseError('Invalid URI, cannot contain multiple hostnames')); - } - - if (result.port) { - return callback(new MongoParseError(`Ports not accepted with '${PROTOCOL_MONGODB_SRV}' URIs`)); - } - // Resolve the SRV record and use the result as the list of hosts to connect to. - const lookupAddress = result.host; + const lookupAddress = options.srvHost; dns.resolveSrv(`_mongodb._tcp.${lookupAddress}`, (err, addresses) => { if (err) return callback(err); @@ -80,428 +69,56 @@ function parseSrvConnectionString(uri: string, options: any, callback: Callback) return callback(new MongoParseError('No addresses found at host')); } - for (let i = 0; i < addresses.length; i++) { - if (!matchesParentDomain(addresses[i].name, result.hostname)) { + for (const { name } of addresses) { + if (!matchesParentDomain(name, lookupAddress)) { return callback( new MongoParseError('Server record does not share hostname with parent URI') ); } } - // Convert the original URL to a non-SRV URL. - result.protocol = 'mongodb'; - result.host = addresses.map((address: any) => `${address.name}:${address.port}`).join(','); - - // Default to SSL true if it's not specified. - if ( - !('ssl' in options) && - (!result.search || !('ssl' in result.query) || result.query.ssl === null) - ) { - result.query.ssl = true; - } + const hostAddresses = addresses.map(r => + HostAddress.fromString(`${r.name}:${r.port ?? 27017}`) + ); // Resolve TXT record and add options from there if they exist. - dns.resolveTxt(lookupAddress, (err?: any, record?: any) => { + dns.resolveTxt(lookupAddress, (err, record) => { if (err) { if (err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') { return callback(err); } - record = null; - } - - if (record) { + } else { if (record.length > 1) { return callback(new MongoParseError('Multiple text records not allowed')); } - record = qs.parse(record[0].join('')); - if (Object.keys(record).some((key: any) => key !== 'authSource' && key !== 'replicaSet')) { + const txtRecordOptions = new URLSearchParams(record[0].join('')); + const txtRecordOptionKeys = [...txtRecordOptions.keys()]; + if (txtRecordOptionKeys.some(key => key !== 'authSource' && key !== 'replicaSet')) { return callback( new MongoParseError('Text record must only set `authSource` or `replicaSet`') ); } - result.query = Object.assign({}, record, result.query); - } - - // Set completed options back into the URL object. - result.search = qs.stringify(result.query); + const source = txtRecordOptions.get('authSource') ?? undefined; + const replicaSet = txtRecordOptions.get('replicaSet') ?? undefined; - const finalString = url.format(result); - parseConnectionString(finalString, options, (err?: any, ret?: any) => { - if (err) { - callback(err); - return; + if (source === '' || replicaSet === '') { + return callback(new MongoParseError('Cannot have empty URI params in DNS TXT Record')); } - callback(undefined, Object.assign({}, ret, { srvHost: lookupAddress })); - }); - }); - }); -} - -/** - * Parses a query string item according to the connection string spec - * - * @param key - The key for the parsed value - * @param value - The value to parse - */ -function parseQueryStringItemValue(key: string, value: any) { - if (Array.isArray(value)) { - // deduplicate and simplify arrays - value = value.filter((v: any, idx: any) => value.indexOf(v) === idx); - if (value.length === 1) value = value[0]; - } else if (value.indexOf(':') > 0) { - value = value.split(',').reduce((result: any, pair: any) => { - const parts = pair.split(':'); - result[parts[0]] = parseQueryStringItemValue(key, parts[1]); - return result; - }, {}); - } else if (value.indexOf(',') > 0) { - value = value.split(',').map((v: any) => { - return parseQueryStringItemValue(key, v); - }); - } else if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') { - value = value.toLowerCase() === 'true'; - } else if (!Number.isNaN(value) && !STRING_OPTIONS.has(key)) { - const numericValue = parseFloat(value); - if (!Number.isNaN(numericValue)) { - value = parseFloat(value); - } - } - - return value; -} - -// Options that are known boolean types -const BOOLEAN_OPTIONS = new Set([ - 'slaveok', - 'slave_ok', - 'sslvalidate', - 'fsync', - 'safe', - 'retrywrites', - 'j' -]); - -// Known string options, only used to bypass Number coercion in `parseQueryStringItemValue` -const STRING_OPTIONS = new Set(['authsource', 'replicaset']); - -// Supported text representations of auth mechanisms -export const AUTH_MECHANISMS = new Set([...Object.values(AuthMechanism)]); - -// Lookup table used to translate normalized (lower-cased) forms of connection string -// options to their expected camelCase version -const CASE_TRANSLATION: any = { - replicaset: 'replicaSet', - connecttimeoutms: 'connectTimeoutMS', - sockettimeoutms: 'socketTimeoutMS', - maxpoolsize: 'maxPoolSize', - minpoolsize: 'minPoolSize', - maxidletimems: 'maxIdleTimeMS', - waitqueuemultiple: 'waitQueueMultiple', - waitqueuetimeoutms: 'waitQueueTimeoutMS', - wtimeoutms: 'wtimeoutMS', - readconcern: 'readConcern', - readconcernlevel: 'readConcernLevel', - readpreference: 'readPreference', - maxstalenessseconds: 'maxStalenessSeconds', - readpreferencetags: 'readPreferenceTags', - authsource: 'authSource', - authmechanism: 'authMechanism', - authmechanismproperties: 'authMechanismProperties', - gssapiservicename: 'gssapiServiceName', - localthresholdms: 'localThresholdMS', - serverselectiontimeoutms: 'serverSelectionTimeoutMS', - serverselectiontryonce: 'serverSelectionTryOnce', - heartbeatfrequencyms: 'heartbeatFrequencyMS', - retrywrites: 'retryWrites', - uuidrepresentation: 'uuidRepresentation', - zlibcompressionlevel: 'zlibCompressionLevel', - tlsallowinvalidcertificates: 'tlsAllowInvalidCertificates', - tlsallowinvalidhostnames: 'tlsAllowInvalidHostnames', - tlsinsecure: 'tlsInsecure', - tlsdisablecertificaterevocationcheck: 'tlsDisableCertificateRevocationCheck', - tlsdisableocspendpointcheck: 'tlsDisableOCSPEndpointCheck', - tlscafile: 'tlsCAFile', - tlscertificatekeyfile: 'tlsCertificateKeyFile', - tlscertificatekeyfilepassword: 'tlsCertificateKeyFilePassword', - wtimeout: 'wTimeoutMS', - j: 'journal', - directconnection: 'directConnection' -}; - -/** - * Sets the value for `key`, allowing for any required translation - * - * @param obj - The object to set the key on - * @param key - The key to set the value for - * @param value - The value to set - * @param options - The options used for option parsing - */ -function applyConnectionStringOption(obj: any, key: string, value: any, options: any) { - // simple key translation - if (key === 'journal') { - key = 'j'; - } else if (key === 'wtimeoutms') { - key = 'wtimeout'; - } - - // more complicated translation - if (BOOLEAN_OPTIONS.has(key)) { - value = value === 'true' || value === true; - } else if (key === 'appname') { - value = decodeURIComponent(value); - } else if (key === 'readconcernlevel') { - obj['readConcernLevel'] = value; - key = 'readconcern'; - value = { level: value }; - } - - // simple validation - if (key === 'compressors') { - value = Array.isArray(value) ? value : [value]; - - if (!value.every((c: CompressorName) => c === 'snappy' || c === 'zlib')) { - throw new MongoParseError( - 'Value for `compressors` must be at least one of: `snappy`, `zlib`' - ); - } - } - - if (key === 'authmechanism' && !AUTH_MECHANISMS.has(value)) { - throw new MongoParseError( - `Value for authMechanism must be one of: ${Array.from(AUTH_MECHANISMS).join( - ', ' - )}, found: ${value}` - ); - } - - if (key === 'readpreference' && !ReadPreference.isValid(value)) { - throw new MongoParseError( - 'Value for `readPreference` must be one of: `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred`, `nearest`' - ); - } - - if (key === 'zlibcompressionlevel' && (value < -1 || value > 9)) { - throw new MongoParseError('zlibCompressionLevel must be an integer between -1 and 9'); - } - - // special cases - if (key === 'compressors' || key === 'zlibcompressionlevel') { - obj.compression = obj.compression || {}; - obj = obj.compression; - } - - if (key === 'authmechanismproperties') { - if (typeof value.SERVICE_NAME === 'string') obj.gssapiServiceName = value.SERVICE_NAME; - if (typeof value.SERVICE_REALM === 'string') obj.gssapiServiceRealm = value.SERVICE_REALM; - if (typeof value.CANONICALIZE_HOST_NAME !== 'undefined') { - obj.gssapiCanonicalizeHostName = value.CANONICALIZE_HOST_NAME; - } - } - - if (key === 'readpreferencetags') { - value = Array.isArray(value) ? splitArrayOfMultipleReadPreferenceTags(value) : [value]; - } - - // set the actual value - if (options.caseTranslate && CASE_TRANSLATION[key]) { - obj[CASE_TRANSLATION[key]] = value; - return; - } - - obj[key] = value; -} - -const USERNAME_REQUIRED_MECHANISMS = new Set([ - 'GSSAPI', - 'MONGODB-CR', - 'PLAIN', - 'SCRAM-SHA-1', - 'SCRAM-SHA-256' -]); - -function splitArrayOfMultipleReadPreferenceTags(value: any) { - const parsedTags: any = []; - - for (let i = 0; i < value.length; i++) { - parsedTags[i] = {}; - value[i].split(',').forEach((individualTag: any) => { - const splitTag = individualTag.split(':'); - parsedTags[i][splitTag[0]] = splitTag[1]; - }); - } - - return parsedTags; -} - -/** - * Modifies the parsed connection string object taking into account expectations we - * have for authentication-related options. - * - * @param parsed - The parsed connection string result - * @returns The parsed connection string result possibly modified for auth expectations - */ -function applyAuthExpectations(parsed: any) { - if (parsed.options == null) { - return; - } - - const options = parsed.options; - const authSource = options.authsource || options.authSource; - if (authSource != null) { - parsed.auth = Object.assign({}, parsed.auth, { db: authSource }); - } - - const authMechanism = options.authmechanism || options.authMechanism; - if (authMechanism != null) { - if ( - USERNAME_REQUIRED_MECHANISMS.has(authMechanism) && - (!parsed.auth || parsed.auth.username == null) - ) { - throw new MongoParseError(`Username required for mechanism \`${authMechanism}\``); - } - - if (authMechanism === 'GSSAPI') { - if (authSource != null && authSource !== '$external') { - throw new MongoParseError( - `Invalid source \`${authSource}\` for mechanism \`${authMechanism}\` specified.` - ); - } - - parsed.auth = Object.assign({}, parsed.auth, { db: '$external' }); - } - - if (authMechanism === 'MONGODB-AWS') { - if (authSource != null && authSource !== '$external') { - throw new MongoParseError( - `Invalid source \`${authSource}\` for mechanism \`${authMechanism}\` specified.` - ); - } - - parsed.auth = Object.assign({}, parsed.auth, { db: '$external' }); - } - - if (authMechanism === 'MONGODB-X509') { - if (parsed.auth && parsed.auth.password != null) { - throw new MongoParseError(`Password not allowed for mechanism \`${authMechanism}\``); - } - - if (authSource != null && authSource !== '$external') { - throw new MongoParseError( - `Invalid source \`${authSource}\` for mechanism \`${authMechanism}\` specified.` - ); - } - - parsed.auth = Object.assign({}, parsed.auth, { db: '$external' }); - } + if (!options.userSpecifiedAuthSource && source) { + options.credentials = MongoCredentials.merge(options.credentials, { source }); + } - if (authMechanism === 'PLAIN') { - if (parsed.auth && parsed.auth.db == null) { - parsed.auth = Object.assign({}, parsed.auth, { db: '$external' }); + if (!options.userSpecifiedReplicaSet && replicaSet) { + options.replicaSet = replicaSet; + } } - } - } - - // default to `admin` if nothing else was resolved - if (parsed.auth && parsed.auth.db == null) { - parsed.auth = Object.assign({}, parsed.auth, { db: 'admin' }); - } - - return parsed; -} - -/** - * Parses a query string according the connection string spec. - * - * @param query - The query string to parse - * @param options - The options used for options parsing - * @returns The parsed query string as an object - */ -function parseQueryString(query: string, options?: AnyOptions): Document { - const result = {} as any; - const parsedQueryString = qs.parse(query); - - checkTLSQueryString(parsedQueryString); - for (const key in parsedQueryString) { - const value = parsedQueryString[key]; - if (value === '' || value == null) { - throw new MongoParseError('Incomplete key value pair for option'); - } - - const normalizedKey = key.toLowerCase(); - const parsedValue = parseQueryStringItemValue(normalizedKey, value); - applyConnectionStringOption(result, normalizedKey, parsedValue, options); - } - - // special cases for known deprecated options - if (result.wtimeout && result.wtimeoutms) { - delete result.wtimeout; - console.warn('Unsupported option `wtimeout` specified'); - } - - return Object.keys(result).length ? result : null; -} - -/// Adds support for modern `tls` variants of out `ssl` options -function translateTLSOptions(queryString: any) { - if (queryString.tls) { - queryString.ssl = queryString.tls; - } - - if (queryString.tlsInsecure) { - queryString.checkServerIdentity = false; - queryString.sslValidate = false; - } else { - Object.assign(queryString, { - checkServerIdentity: queryString.tlsAllowInvalidHostnames ? false : true, - sslValidate: queryString.tlsAllowInvalidCertificates ? false : true + callback(undefined, hostAddresses); }); - } - - if (queryString.tlsCAFile) { - queryString.ssl = true; - queryString.sslCA = queryString.tlsCAFile; - } - - if (queryString.tlsCertificateKeyFile) { - queryString.ssl = true; - if (queryString.tlsCertificateFile) { - queryString.sslCert = queryString.tlsCertificateFile; - queryString.sslKey = queryString.tlsCertificateKeyFile; - } else { - queryString.sslKey = queryString.tlsCertificateKeyFile; - queryString.sslCert = queryString.tlsCertificateKeyFile; - } - } - - if (queryString.tlsCertificateKeyFilePassword) { - queryString.ssl = true; - queryString.sslPass = queryString.tlsCertificateKeyFilePassword; - } - - return queryString; -} - -/** - * Checks a query string for invalid tls options according to the URI options spec. - * - * @param queryString - The parsed query string - * @throws MongoParseError if tls and ssl options contain conflicts - */ -function checkTLSQueryString(queryString: any) { - const queryStringKeys = Object.keys(queryString); - - const tlsValue = assertTlsOptionsAreEqual('tls', queryString, queryStringKeys); - const sslValue = assertTlsOptionsAreEqual('ssl', queryString, queryStringKeys); - - if (tlsValue != null && sslValue != null) { - if (tlsValue !== sslValue) { - throw new MongoParseError('All values of `tls` and `ssl` must be the same.'); - } - } + }); } /** @@ -526,240 +143,12 @@ export function checkTLSOptions(options: AnyOptions): void { check('tlsDisableCertificateRevocationCheck', 'tlsDisableOCSPEndpointCheck'); } -/** - * Checks a query string to ensure all tls/ssl options are the same. - * - * @param optionName - The key (tls or ssl) to check - * @param queryString - The parsed query string - * @param queryStringKeys - list of keys in the query string - * @throws MongoParseError - * @returns The value of the tls/ssl option - */ -function assertTlsOptionsAreEqual(optionName: string, queryString: any, queryStringKeys: any) { - const queryStringHasTLSOption = queryStringKeys.indexOf(optionName) !== -1; - - let optionValue; - if (Array.isArray(queryString[optionName])) { - optionValue = queryString[optionName][0]; - } else { - optionValue = queryString[optionName]; - } - - if (queryStringHasTLSOption) { - if (Array.isArray(queryString[optionName])) { - const firstValue = queryString[optionName][0]; - queryString[optionName].forEach((tlsValue: any) => { - if (tlsValue !== firstValue) { - throw new MongoParseError(`All values of ${optionName} must be the same.`); - } - }); - } - } - - return optionValue; -} - -const PROTOCOL_MONGODB = 'mongodb'; -const PROTOCOL_MONGODB_SRV = 'mongodb+srv'; -const SUPPORTED_PROTOCOLS = [PROTOCOL_MONGODB, PROTOCOL_MONGODB_SRV]; - -interface ParseConnectionStringOptions extends Partial { - /** Whether the parser should translate options back into camelCase after normalization */ - caseTranslate?: boolean; -} - -/** Parses a MongoDB connection string */ -export function parseConnectionString(uri: string, callback: Callback): void; -export function parseConnectionString( - uri: string, - options: ParseConnectionStringOptions, - callback: Callback -): void; -export function parseConnectionString( - uri: string, - options?: ParseConnectionStringOptions | Callback, - _callback?: Callback -): void { - let callback = _callback as Callback; - if (typeof options === 'function') { - callback = options; - options = {}; - } - options = { caseTranslate: true, ...options }; - - // Check for bad uris before we parse - try { - url.parse(uri); - } catch (e) { - return callback(new MongoParseError('URI malformed, cannot be parsed')); - } - - const cap = uri.match(HOSTS_RX); - if (!cap) { - return callback(new MongoParseError('Invalid connection string')); - } - - const protocol = cap[1]; - if (SUPPORTED_PROTOCOLS.indexOf(protocol) === -1) { - return callback(new MongoParseError('Invalid protocol provided')); - } - - const dbAndQuery = cap[4].split('?'); - const db = dbAndQuery.length > 0 ? dbAndQuery[0] : null; - const query = dbAndQuery.length > 1 ? dbAndQuery[1] : ''; - - let parsedOptions; - try { - // this just parses the query string NOT the connection options object - parsedOptions = parseQueryString(query, options); - // this merges the options object with the query string object above - parsedOptions = Object.assign({}, parsedOptions, options); - checkTLSOptions(parsedOptions); - } catch (parseError) { - return callback(parseError); - } - - parsedOptions = Object.assign({}, parsedOptions, options); - - if (protocol === PROTOCOL_MONGODB_SRV) { - return parseSrvConnectionString(uri, parsedOptions, callback); - } - - const auth: any = { - username: null, - password: null, - db: db && db !== '' ? qs.unescape(db) : null - }; - if (parsedOptions.auth) { - // maintain support for legacy options passed into `MongoClient` - if (parsedOptions.auth.username) auth.username = parsedOptions.auth.username; - if (parsedOptions.auth.user) auth.username = parsedOptions.auth.user; - if (parsedOptions.auth.password) auth.password = parsedOptions.auth.password; - } else { - if (parsedOptions.username) auth.username = parsedOptions.username; - if (parsedOptions.user) auth.username = parsedOptions.user; - if (parsedOptions.password) auth.password = parsedOptions.password; - } - - if (cap[4].split('?')[0].indexOf('@') !== -1) { - return callback(new MongoParseError('Unescaped slash in userinfo section')); - } - - const authorityParts: any = cap[3].split('@'); - if (authorityParts.length > 2) { - return callback(new MongoParseError('Unescaped at-sign in authority section')); - } - - if (authorityParts[0] == null || authorityParts[0] === '') { - return callback(new MongoParseError('No username provided in authority section')); - } - - if (authorityParts.length > 1) { - const authParts = authorityParts.shift().split(':'); - if (authParts.length > 2) { - return callback(new MongoParseError('Unescaped colon in authority section')); - } - - if (authParts[0] === '') { - return callback(new MongoParseError('Invalid empty username provided')); - } - - if (!auth.username) auth.username = qs.unescape(authParts[0]); - if (!auth.password) auth.password = authParts[1] ? qs.unescape(authParts[1]) : null; - } - - let hostParsingError = null; - const hosts = authorityParts - .shift() - .split(',') - .map((host: any) => { - const parsedHost: any = url.parse(`mongodb://${host}`); - if (parsedHost.path === '/:') { - hostParsingError = new MongoParseError('Double colon in host identifier'); - return null; - } - - // heuristically determine if we're working with a domain socket - if (host.match(/\.sock/)) { - parsedHost.hostname = qs.unescape(host); - parsedHost.port = null; - } - - if (Number.isNaN(parsedHost.port)) { - hostParsingError = new MongoParseError('Invalid port (non-numeric string)'); - return; - } - - const result = { - host: parsedHost.hostname, - port: parsedHost.port ? parseInt(parsedHost.port) : 27017 - }; - - if (result.port === 0) { - hostParsingError = new MongoParseError('Invalid port (zero) with hostname'); - return; - } - - if (result.port > 65535) { - hostParsingError = new MongoParseError('Invalid port (larger than 65535) with hostname'); - return; - } - - if (result.port < 0) { - hostParsingError = new MongoParseError('Invalid port (negative number)'); - return; - } - - return result; - }) - .filter((host: any) => !!host); - - if (hostParsingError) { - return callback(hostParsingError); - } - - if (hosts.length === 0 || hosts[0].host === '' || hosts[0].host === null) { - return callback(new MongoParseError('No hostname or hostnames provided in connection string')); - } - - const directConnection = !!parsedOptions.directConnection; - if (directConnection && hosts.length !== 1) { - // If the option is set to true, the driver MUST validate that there is exactly one host given - // in the host list in the URI, and fail client creation otherwise. - return callback(new MongoParseError('directConnection option requires exactly one host')); - } - - const result = { - hosts: hosts, - auth: auth.db || auth.username ? auth : null, - options: Object.keys(parsedOptions).length ? parsedOptions : {} - } as any; - - if (result.auth && result.auth.db) { - result.defaultDatabase = result.auth.db; - } else { - result.defaultDatabase = 'test'; - } - - // support modern `tls` variants to SSL options - result.options = translateTLSOptions(result.options); - - try { - applyAuthExpectations(result); - } catch (authError) { - return callback(authError); - } - - callback(undefined, result); -} - -// NEW PARSER WORK... - const HOSTS_REGEX = new RegExp( String.raw`(?mongodb(?:\+srv|)):\/\/(?:(?[^:]*)(?::(?[^@]*))?@)?(?(?!:)[^\/?@]+)(?.*)` ); -function parseURI(uri: string): { srv: boolean; url: URL; hosts: string[] } { +/** @internal */ +export function parseURI(uri: string): { isSRV: boolean; url: URL; hosts: string[] } { const match = uri.match(HOSTS_REGEX); if (!match) { throw new MongoParseError(`Invalid connection string ${uri}`); @@ -798,19 +187,19 @@ function parseURI(uri: string): { srv: boolean; url: URL; hosts: string[] } { if (typeof username === 'string') authString += username; if (typeof password === 'string') authString += `:${password}`; - const srv = protocol.includes('srv'); + const isSRV = protocol.includes('srv'); const hostList = hosts.split(','); const url = new URL(`${protocol.toLowerCase()}://${authString}@dummyHostname${rest}`); - if (srv && hostList.length !== 1) { + if (isSRV && hostList.length !== 1) { throw new MongoParseError('mongodb+srv URI cannot have multiple service names'); } - if (srv && hostList[0].includes(':')) { + if (isSRV && hostList[0].includes(':')) { throw new MongoParseError('mongodb+srv URI cannot have port number'); } return { - srv, + isSRV, url, hosts: hosts.split(',') }; @@ -846,18 +235,25 @@ function toRecord(value: string): Record { const keyValuePairs = value.split(','); for (const keyValue of keyValuePairs) { const [key, value] = keyValue.split(':'); - record[key] = value; + if (typeof value === 'undefined') { + throw new MongoParseError('Cannot have undefined values in key value pairs'); + } + try { + // try to get a boolean + record[key] = getBoolean('', value); + } catch { + try { + // try to get a number + record[key] = getInt('', value); + } catch { + // keep value as a string + record[key] = value; + } + } } return record; } -const DEFAULT_PK_FACTORY = { - createPk(): ObjectId { - // We prefer not to rely on ObjectId having a createPk method - return new ObjectId(); - } -}; - class CaseInsensitiveMap extends Map { constructor(entries: Array<[string, any]> = []) { super(entries.map(([k, v]) => [k.toLowerCase(), v])); @@ -871,33 +267,54 @@ class CaseInsensitiveMap extends Map { set(k: string, v: any) { return super.set(k.toLowerCase(), v); } + delete(k: string): boolean { + return super.delete(k.toLowerCase()); + } } export function parseOptions( uri: string, + mongoClient: MongoClient | MongoClientOptions | undefined = undefined, options: MongoClientOptions = {} -): Readonly { - const { url, hosts, srv } = parseURI(uri); +): MongoOptions { + if (typeof mongoClient !== 'undefined' && !(mongoClient instanceof MongoClient)) { + options = mongoClient; + mongoClient = undefined; + } - // TODO(NODE-2704): Move back to test/tools/runner/config.js - options = { ...options }; - Reflect.deleteProperty(options, 'host'); - Reflect.deleteProperty(options, 'port'); + const { url, hosts, isSRV } = parseURI(uri); const mongoOptions = Object.create(null); - mongoOptions.hosts = srv ? [{ host: hosts[0], type: 'srv' }] : hosts.map(toHostArray); - mongoOptions.srv = srv; - mongoOptions.dbName = decodeURIComponent( - url.pathname[0] === '/' ? url.pathname.slice(1) : url.pathname - ); - mongoOptions.credentials = new MongoCredentials({ - ...mongoOptions.credentials, - source: mongoOptions.dbName, - username: typeof url.username === 'string' ? decodeURIComponent(url.username) : undefined, - password: typeof url.password === 'string' ? decodeURIComponent(url.password) : undefined - }); + mongoOptions.hosts = isSRV ? [] : hosts.map(HostAddress.fromString); + if (isSRV) { + // SRV Record is resolved upon connecting + mongoOptions.srvHost = hosts[0]; + options.tls = true; + } const urlOptions = new CaseInsensitiveMap(); + + if (url.pathname !== '/' && url.pathname !== '') { + const dbName = decodeURIComponent( + url.pathname[0] === '/' ? url.pathname.slice(1) : url.pathname + ); + if (dbName) { + urlOptions.set('dbName', [dbName]); + } + } + + if (url.username !== '') { + const auth: Document = { + username: decodeURIComponent(url.username) + }; + + if (typeof url.password === 'string') { + auth.password = decodeURIComponent(url.password); + } + + urlOptions.set('auth', [auth]); + } + for (const key of url.searchParams.keys()) { const values = [...url.searchParams.getAll(key)]; @@ -905,27 +322,24 @@ export function parseOptions( throw new MongoParseError('URI cannot contain options with no value'); } - if (urlOptions.has(key)) { - urlOptions.get(key)?.push(...values); - } else { + if (key.toLowerCase() === 'authsource' && urlOptions.has('authSource')) { + // If authSource is an explicit key in the urlOptions we need to remove the implicit dbName + urlOptions.delete('authSource'); + } + + if (!urlOptions.has(key)) { urlOptions.set(key, values); } } const objectOptions = new CaseInsensitiveMap(Object.entries(options)); - const defaultOptions = new CaseInsensitiveMap( - Object.entries(OPTIONS) - .filter(([, descriptor]) => typeof descriptor.default !== 'undefined') - .map(([k, d]) => [k, d.default]) - ); - const allOptions = new CaseInsensitiveMap(); const allKeys = new Set([ ...urlOptions.keys(), ...objectOptions.keys(), - ...defaultOptions.keys() + ...DEFAULT_OPTIONS.keys() ]); for (const key of allKeys) { @@ -936,22 +350,89 @@ export function parseOptions( if (objectOptions.has(key)) { values.push(objectOptions.get(key)); } - if (defaultOptions.has(key)) { - values.push(defaultOptions.get(key)); + if (DEFAULT_OPTIONS.has(key)) { + values.push(DEFAULT_OPTIONS.get(key)); } allOptions.set(key, values); } + const unsupportedOptions = setDifference( + allKeys, + Array.from(Object.keys(OPTIONS)).map(s => s.toLowerCase()) + ); + if (unsupportedOptions.size !== 0) { + throw new MongoParseError( + `options ${Array.from(unsupportedOptions).join(', ')} are not supported` + ); + } + for (const [key, descriptor] of Object.entries(OPTIONS)) { const values = allOptions.get(key); if (!values || values.length === 0) continue; setOption(mongoOptions, key, descriptor, values); } - mongoOptions.credentials?.validate(); + if (mongoOptions.credentials) { + const gssapiOrX509 = + mongoOptions.credentials.mechanism === AuthMechanism.MONGODB_GSSAPI || + mongoOptions.credentials.mechanism === AuthMechanism.MONGODB_X509; + + if ( + gssapiOrX509 && + allOptions.has('authSource') && + mongoOptions.credentials.source !== '$external' + ) { + // If authSource was explicitly given and its incorrect, we error + throw new MongoParseError( + `${mongoOptions.credentials} can only have authSource set to '$external'` + ); + } + + if (!gssapiOrX509 && mongoOptions.dbName && !allOptions.has('authSource')) { + // inherit the dbName unless GSSAPI or X509, then silently ignore dbName + // and there was no specific authSource given + mongoOptions.credentials = MongoCredentials.merge(mongoOptions.credentials, { + source: mongoOptions.dbName + }); + } + + mongoOptions.credentials.validate(); + } + + if (!mongoOptions.dbName) { + // dbName default is applied here because of the credential validation above + mongoOptions.dbName = 'test'; + } + + if (allOptions.has('tls')) { + if (new Set(allOptions.get('tls')?.map(getBoolean)).size !== 1) { + throw new MongoParseError('All values of tls must be the same.'); + } + } + + if (allOptions.has('ssl')) { + if (new Set(allOptions.get('ssl')?.map(getBoolean)).size !== 1) { + throw new MongoParseError('All values of ssl must be the same.'); + } + } + checkTLSOptions(mongoOptions); + if (mongoClient && options.autoEncryption) { + mongoOptions.autoEncrypter = createAutoEncrypter(mongoClient); + } + if (options.promiseLibrary) PromiseProvider.set(options.promiseLibrary); + + if (mongoOptions.directConnection && typeof mongoOptions.srvHost === 'string') { + throw new MongoParseError('directConnection not supported with SRV URI'); + } - return Object.freeze(mongoOptions) as Readonly; + // Potential SRV Overrides + mongoOptions.userSpecifiedAuthSource = + objectOptions.has('authSource') || urlOptions.has('authSource'); + mongoOptions.userSpecifiedReplicaSet = + objectOptions.has('replicaSet') || urlOptions.has('replicaSet'); + + return mongoOptions; } function setOption( @@ -964,7 +445,8 @@ function setOption( const name = target ?? key; if (deprecated) { - console.warn(`${key} is a deprecated option`); + const deprecatedMsg = typeof deprecated === 'string' ? `: ${deprecated}` : ''; + console.warn(`${key} is a deprecated option${deprecatedMsg}`); } switch (type) { @@ -985,7 +467,7 @@ function setOption( break; case 'record': if (!isRecord(values[0])) { - throw new TypeError(`${name} must be an object`); + throw new MongoParseError(`${name} must be an object`); } mongoOptions[name] = values[0]; break; @@ -1003,44 +485,12 @@ function setOption( } } -function toHostArray(hostString: string) { - const parsedHost = new URL(`mongodb://${hostString.split(' ').join('%20')}`); - - let socketPath; - if (hostString.endsWith('.sock')) { - // heuristically determine if we're working with a domain socket - socketPath = decodeURIComponent(hostString); - } - - let ipv6SanitizedHostName; - if (parsedHost.hostname.startsWith('[') && parsedHost.hostname.endsWith(']')) { - ipv6SanitizedHostName = parsedHost.hostname.substring(1, parsedHost.hostname.length - 1); - } - - const result: HostAddress = socketPath - ? { - host: socketPath, - type: 'unix' - } - : { - host: decodeURIComponent(ipv6SanitizedHostName ?? parsedHost.hostname), - port: parsedHost.port ? parseInt(parsedHost.port) : 27017, - type: 'tcp' - }; - - if (result.type === 'tcp' && result.port === 0) { - throw new MongoParseError('Invalid port (zero) with hostname'); - } - - return result; -} - interface OptionDescriptor { target?: string; type?: 'boolean' | 'int' | 'uint' | 'record' | 'string' | 'any'; default?: any; - deprecated?: boolean; + deprecated?: boolean | string; /** * @param name - the original option name * @param options - the options so far for resolution @@ -1051,16 +501,18 @@ interface OptionDescriptor { export const OPTIONS = { appName: { - target: 'driverInfo', + target: 'metadata', transform({ options, values: [value] }): DriverInfo { - return { ...options.driverInfo, name: String(value) }; + return makeClientMetadata({ ...options.driverInfo, appName: String(value) }); } }, auth: { target: 'credentials', transform({ name, options, values: [value] }): MongoCredentials { if (!isRecord(value, ['username', 'password'] as const)) { - throw new TypeError(`${name} must be an object with 'username' and 'password' properties`); + throw new MongoParseError( + `${name} must be an object with 'username' and 'password' properties` + ); } return MongoCredentials.merge(options.credentials, { username: value.username, @@ -1074,19 +526,20 @@ export const OPTIONS = { const mechanisms = Object.values(AuthMechanism); const [mechanism] = mechanisms.filter(m => m.match(RegExp(String.raw`\b${value}\b`, 'i'))); if (!mechanism) { - throw new TypeError(`authMechanism one of ${mechanisms}, got ${value}`); + throw new MongoParseError(`authMechanism one of ${mechanisms}, got ${value}`); } - let source = options.credentials.source; // some mechanisms have '$external' as the Auth Source + let source = options.credentials?.source; if ( mechanism === AuthMechanism.MONGODB_PLAIN || mechanism === AuthMechanism.MONGODB_GSSAPI || mechanism === AuthMechanism.MONGODB_AWS || mechanism === AuthMechanism.MONGODB_X509 ) { + // some mechanisms have '$external' as the Auth Source source = '$external'; } - let password: string | undefined = options.credentials.password; + let password = options.credentials?.password; if (mechanism === AuthMechanism.MONGODB_X509 && password === '') { password = undefined; } @@ -1104,7 +557,7 @@ export const OPTIONS = { value = toRecord(value); } if (!isRecord(value)) { - throw new TypeError('AuthMechanismProperties must be an object'); + throw new MongoParseError('AuthMechanismProperties must be an object'); } return MongoCredentials.merge(options.credentials, { mechanismProperties: value }); } @@ -1112,7 +565,8 @@ export const OPTIONS = { authSource: { target: 'credentials', transform({ options, values: [value] }): MongoCredentials { - return MongoCredentials.merge(options.credentials, { source: String(value) }); + const source = String(value); + return MongoCredentials.merge(options.credentials, { source }); } }, autoEncryption: { @@ -1127,25 +581,10 @@ export const OPTIONS = { values: [value] }): boolean | ((hostname: string, cert: Document) => Error | undefined) { if (typeof value !== 'boolean' && typeof value !== 'function') - throw new TypeError('check server identity must be a boolean or custom function'); + throw new MongoParseError('check server identity must be a boolean or custom function'); return value as boolean | ((hostname: string, cert: Document) => Error | undefined); } }, - compression: { - default: 'none', - target: 'compressors', - transform({ values }) { - const compressionList = new Set(); - for (const c of values) { - if (['none', 'snappy', 'zlib'].includes(String(c))) { - compressionList.add(String(c)); - } else { - throw new TypeError(`${c} is not a valid compression mechanism`); - } - } - return [...compressionList]; - } - }, compressors: { default: 'none', target: 'compressors', @@ -1156,7 +595,7 @@ export const OPTIONS = { if (['none', 'snappy', 'zlib'].includes(String(c))) { compressionList.add(String(c)); } else { - throw new TypeError(`${c} is not a valid compression mechanism`); + throw new MongoParseError(`${c} is not a valid compression mechanism`); } } } @@ -1168,7 +607,6 @@ export const OPTIONS = { type: 'uint' }, dbName: { - default: 'test', type: 'string' }, directConnection: { @@ -1176,8 +614,15 @@ export const OPTIONS = { type: 'boolean' }, driverInfo: { - default: {}, - type: 'record' + target: 'metadata', + default: makeClientMetadata(), + transform({ options, values: [value] }) { + if (!isRecord(value)) throw new MongoParseError('DriverInfo must be an object'); + return makeClientMetadata({ + driverInfo: value, + appName: options.metadata?.application?.name + }); + } }, family: { transform({ name, values: [value] }): 4 | 6 { @@ -1185,7 +630,7 @@ export const OPTIONS = { if (transformValue === 4 || transformValue === 6) { return transformValue; } - throw new TypeError(`Option 'family' must be 4 or 6 got ${transformValue}.`); + throw new MongoParseError(`Option 'family' must be 4 or 6 got ${transformValue}.`); } }, fieldsAsRaw: { @@ -1196,6 +641,7 @@ export const OPTIONS = { type: 'boolean' }, fsync: { + deprecated: 'Please use journal instead', target: 'writeConcern', transform({ name, options, values: [value] }): WriteConcern { const wc = WriteConcern.fromOptions({ @@ -1204,10 +650,10 @@ export const OPTIONS = { fsync: getBoolean(name, value) } }); - if (!wc) throw new TypeError(`Unable to make a writeConcern from fsync=${value}`); + if (!wc) throw new MongoParseError(`Unable to make a writeConcern from fsync=${value}`); return wc; } - }, + } as OptionDescriptor, heartbeatFrequencyMS: { default: 10000, type: 'uint' @@ -1216,6 +662,7 @@ export const OPTIONS = { type: 'boolean' }, j: { + deprecated: 'Please use journal instead', target: 'writeConcern', transform({ name, options, values: [value] }): WriteConcern { const wc = WriteConcern.fromOptions({ @@ -1224,10 +671,10 @@ export const OPTIONS = { journal: getBoolean(name, value) } }); - if (!wc) throw new TypeError(`Unable to make a writeConcern from journal=${value}`); + if (!wc) throw new MongoParseError(`Unable to make a writeConcern from journal=${value}`); return wc; } - }, + } as OptionDescriptor, journal: { target: 'writeConcern', transform({ name, options, values: [value] }): WriteConcern { @@ -1237,7 +684,7 @@ export const OPTIONS = { journal: getBoolean(name, value) } }); - if (!wc) throw new TypeError(`Unable to make a writeConcern from journal=${value}`); + if (!wc) throw new MongoParseError(`Unable to make a writeConcern from journal=${value}`); return wc; } }, @@ -1250,7 +697,7 @@ export const OPTIONS = { type: 'uint' }, localThresholdMS: { - default: 0, + default: 15, type: 'uint' }, logger: { @@ -1279,7 +726,17 @@ export const OPTIONS = { type: 'uint' }, maxStalenessSeconds: { - type: 'uint' + target: 'readPreference', + transform({ name, options, values: [value] }) { + const maxStalenessSeconds = getUint(name, value); + if (options.readPreference) { + return ReadPreference.fromOptions({ + readPreference: { ...options.readPreference, maxStalenessSeconds } + }); + } else { + return new ReadPreference('secondary', undefined, { maxStalenessSeconds }); + } + } }, minInternalBufferSize: { type: 'uint' @@ -1289,6 +746,7 @@ export const OPTIONS = { type: 'uint' }, minHeartbeatFrequencyMS: { + default: 500, type: 'uint' }, monitorCommands: { @@ -1305,38 +763,19 @@ export const OPTIONS = { default: true, type: 'boolean' }, - numberOfRetries: { - default: 5, - type: 'int' - }, - password: { - target: 'credentials', - transform({ values: [password], options }) { - if (typeof password !== 'string') { - throw new TypeError('pass must be a string'); - } - return MongoCredentials.merge(options.credentials, { password }); - } - }, pkFactory: { default: DEFAULT_PK_FACTORY, - target: 'createPk', transform({ values: [value] }): PkFactory { if (isRecord(value, ['createPk'] as const) && typeof value.createPk === 'function') { return value as PkFactory; } - throw new TypeError( + throw new MongoParseError( `Option pkFactory must be an object with a createPk function, got ${value}` ); } }, - platform: { - target: 'driverInfo', - transform({ values: [value], options }) { - return { ...options.driverInfo, platform: String(value) }; - } - } as OptionDescriptor, promiseLibrary: { + deprecated: true, type: 'any' }, promoteBuffers: { @@ -1400,21 +839,27 @@ export const OPTIONS = { } }, readPreferenceTags: { - transform({ values }) { - const tags: TagSet = Object.create(null); + target: 'readPreference', + transform({ values, options }) { + const readPreferenceTags = []; for (const tag of values) { + const readPreferenceTag: TagSet = Object.create(null); if (typeof tag === 'string') { for (const [k, v] of Object.entries(toRecord(tag))) { - tags[k] = v; + readPreferenceTag[k] = v; } } if (isRecord(tag)) { for (const [k, v] of Object.entries(tag)) { - tags[k] = v; + readPreferenceTag[k] = v; } } + readPreferenceTags.push(readPreferenceTag); } - return tags; + return ReadPreference.fromOptions({ + readPreference: options.readPreference, + readPreferenceTags + }); } }, replicaSet: { @@ -1444,27 +889,31 @@ export const OPTIONS = { }, ssl: { target: 'tls', - deprecated: true, type: 'boolean' }, sslCA: { - deprecated: true, target: 'ca', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, sslCRL: { target: 'crl', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, sslCert: { - deprecated: true, target: 'cert', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, sslKey: { - deprecated: true, target: 'key', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, sslPass: { deprecated: true, @@ -1486,38 +935,43 @@ export const OPTIONS = { }, tlsCAFile: { target: 'ca', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, tlsCertificateFile: { target: 'cert', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, tlsCertificateKeyFile: { target: 'key', - type: 'any' + transform({ values: [value] }) { + return fs.readFileSync(String(value), { encoding: 'ascii' }); + } }, tlsCertificateKeyFilePassword: { target: 'passphrase', type: 'any' }, tlsInsecure: { - type: 'boolean' + transform({ name, options, values: [value] }) { + const tlsInsecure = getBoolean(name, value); + if (tlsInsecure) { + options.checkServerIdentity = undefined; + options.rejectUnauthorized = false; + } else { + options.checkServerIdentity = options.tlsAllowInvalidHostnames ? undefined : (true as any); + options.rejectUnauthorized = options.tlsAllowInvalidCertificates ? false : true; + } + return tlsInsecure; + } }, useRecoveryToken: { + default: true, type: 'boolean' }, - username: { - target: 'credentials', - transform({ values: [value], options }) { - return MongoCredentials.merge(options.credentials, { username: String(value) }); - } - }, - version: { - target: 'driverInfo', - transform({ values: [value], options }) { - return { ...options.driverInfo, version: String(value) }; - } - } as OptionDescriptor, w: { target: 'writeConcern', transform({ values: [value], options }) { @@ -1542,8 +996,9 @@ export const OPTIONS = { throw new MongoParseError(`WriteConcern must be an object, got ${JSON.stringify(value)}`); } - }, + } as OptionDescriptor, wtimeout: { + deprecated: 'Please use wtimeoutMS instead', target: 'writeConcern', transform({ values: [value], options }) { const wc = WriteConcern.fromOptions({ @@ -1555,7 +1010,7 @@ export const OPTIONS = { if (wc) return wc; throw new MongoParseError(`Cannot make WriteConcern from wtimeout`); } - }, + } as OptionDescriptor, wtimeoutMS: { target: 'writeConcern', transform({ values: [value], options }) { @@ -1572,5 +1027,39 @@ export const OPTIONS = { zlibCompressionLevel: { default: 0, type: 'int' - } + }, + // Custom types for modifying core behavior + connectionType: { type: 'any' }, + srvPoller: { type: 'any' }, + // Accepted NodeJS Options + minDHSize: { type: 'any' }, + pskCallback: { type: 'any' }, + secureContext: { type: 'any' }, + enableTrace: { type: 'any' }, + requestCert: { type: 'any' }, + rejectUnauthorized: { type: 'any' }, + ALPNProtocols: { type: 'any' }, + SNICallback: { type: 'any' }, + session: { type: 'any' }, + requestOCSP: { type: 'any' }, + localAddress: { type: 'any' }, + localPort: { type: 'any' }, + hints: { type: 'any' }, + lookup: { type: 'any' }, + ca: { type: 'any' }, + cert: { type: 'any' }, + ciphers: { type: 'any' }, + crl: { type: 'any' }, + ecdhCurve: { type: 'any' }, + key: { type: 'any' }, + passphrase: { type: 'any' }, + pfx: { type: 'any' }, + secureProtocol: { type: 'any' }, + index: { type: 'any' } } as Record; + +export const DEFAULT_OPTIONS = new CaseInsensitiveMap( + Object.entries(OPTIONS) + .filter(([, descriptor]) => typeof descriptor.default !== 'undefined') + .map(([k, d]) => [k, d.default]) +); diff --git a/src/cursor/abstract_cursor.ts b/src/cursor/abstract_cursor.ts index 0d930b40a0..9183154636 100644 --- a/src/cursor/abstract_cursor.ts +++ b/src/cursor/abstract_cursor.ts @@ -1,4 +1,4 @@ -import { Callback, maybePromise, MongoDBNamespace } from '../utils'; +import { Callback, maybePromise, MongoDBNamespace, ns } from '../utils'; import { Long, Document, BSONSerializeOptions, pluckBSONSerializeOptions } from '../bson'; import { ClientSession } from '../sessions'; import { MongoError } from '../error'; @@ -637,7 +637,7 @@ function next( : response.cursor.id; if (response.cursor.ns) { - cursor[kNamespace] = MongoDBNamespace.fromString(response.cursor.ns); + cursor[kNamespace] = ns(response.cursor.ns); } cursor[kDocuments] = response.cursor.firstBatch; diff --git a/src/db.ts b/src/db.ts index f76b138e55..1948228c82 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,16 +1,16 @@ import { deprecate } from 'util'; import { - emitDeprecatedOptionWarning, Callback, resolveOptions, filterOptions, deprecateOptions, MongoDBNamespace, - getTopology + getTopology, + DEFAULT_PK_FACTORY } from './utils'; import { loadAdmin } from './dynamic_loaders'; import { AggregationCursor } from './cursor/aggregation_cursor'; -import { ObjectId, Code, Document, BSONSerializeOptions, resolveBSONOptions } from './bson'; +import { Code, Document, BSONSerializeOptions, resolveBSONOptions } from './bson'; import { ReadPreference, ReadPreferenceLike } from './read_preference'; import { MongoError } from './error'; import { Collection, CollectionOptions } from './collection'; @@ -54,7 +54,7 @@ import type { MongoClient, PkFactory } from './mongo_client'; import type { Admin } from './admin'; // Allowed parameters -const legalOptionNames = [ +const DB_OPTIONS_ALLOW_LIST = [ 'writeConcern', 'readPreference', 'readPreferenceTags', @@ -146,10 +146,9 @@ export class Db { */ constructor(client: MongoClient, databaseName: string, options?: DbOptions) { options = options ?? {}; - emitDeprecatedOptionWarning(options, ['promiseLibrary']); // Filter the options - options = filterOptions(options, legalOptionNames); + options = filterOptions(options, DB_OPTIONS_ALLOW_LIST); // Ensure we have a valid db name validateDatabaseName(databaseName); @@ -167,12 +166,7 @@ export class Db { // Merge bson options bsonOptions: resolveBSONOptions(options, client), // Set up the primary key factory or fallback to ObjectId - pkFactory: options?.pkFactory ?? { - createPk() { - // We prefer not to rely on ObjectId having a createPk method - return new ObjectId(); - } - }, + pkFactory: options?.pkFactory ?? DEFAULT_PK_FACTORY, // ReadConcern readConcern: ReadConcern.fromOptions(options), writeConcern: WriteConcern.fromOptions(options), diff --git a/src/index.ts b/src/index.ts index 99a1464598..62ab65cac2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,7 +118,7 @@ export type { OpGetMoreOptions, OpQueryOptions } from './cmap/commands'; -export type { Stream } from './cmap/connect'; +export type { Stream, LEGAL_TLS_SOCKET_OPTIONS, LEGAL_TCP_SOCKET_OPTIONS } from './cmap/connect'; export type { Connection, ConnectionOptions, @@ -173,13 +173,15 @@ export type { MongoClientOptions, WithSessionCallback, PkFactory, - MongoURIOptions, LogLevel, LogLevelId, Auth, DriverInfo, MongoOptions, - HostAddress + SupportedNodeConnectionOptions, + SupportedTLSConnectionOptions, + SupportedTLSSocketOptions, + SupportedSocketOptions } from './mongo_client'; export type { AddUserOptions } from './operations/add_user'; export type { @@ -277,8 +279,7 @@ export type { ServerCapabilities, ConnectOptions, SelectServerOptions, - ServerSelectionCallback, - ServerAddress + ServerSelectionCallback } from './sdam/topology'; export type { TopologyDescription, TopologyDescriptionOptions } from './sdam/topology_description'; export type { @@ -296,7 +297,8 @@ export type { ClientMetadataOptions, MongoDBNamespace, InterruptibleAsyncInterval, - BufferPool + BufferPool, + HostAddress } from './utils'; export type { WriteConcern, W, WriteConcernOptions, WriteConcernSettings } from './write_concern'; export type { ExecutionResult } from './operations/execute_operation'; diff --git a/src/logger.ts b/src/logger.ts index 5d52291cf6..5856df870d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -10,7 +10,7 @@ let level: LoggerLevel; const pid = process.pid; // current logger -let currentLogger: LoggerFunction; +let currentLogger: LoggerFunction = console.warn; /** @public */ export enum LoggerLevel { @@ -48,10 +48,8 @@ export class Logger { this.className = className; // Current logger - if (options.logger) { + if (!(options.logger instanceof Logger) && typeof options.logger === 'function') { currentLogger = options.logger; - } else if (currentLogger == null) { - currentLogger = console.log; } // Set level of logging, default is error diff --git a/src/mongo_client.ts b/src/mongo_client.ts index cdfc8f984c..858320c6d7 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -1,26 +1,37 @@ import { Db, DbOptions } from './db'; import { EventEmitter } from 'events'; import { ChangeStream, ChangeStreamOptions } from './change_stream'; -import { ReadPreference, ReadPreferenceModeId } from './read_preference'; +import type { ReadPreference, ReadPreferenceModeId } from './read_preference'; import { MongoError, AnyError } from './error'; -import { WriteConcern, W, WriteConcernSettings } from './write_concern'; -import { maybePromise, MongoDBNamespace, Callback, resolveOptions } from './utils'; +import type { W, WriteConcern } from './write_concern'; +import { + maybePromise, + MongoDBNamespace, + Callback, + resolveOptions, + ClientMetadata, + ns, + HostAddress +} from './utils'; import { deprecate } from 'util'; -import { connect, validOptions } from './operations/connect'; +import { connect } from './operations/connect'; import { PromiseProvider } from './promise_provider'; -import { Logger } from './logger'; -import { ReadConcern, ReadConcernLevelId, ReadConcernLike } from './read_concern'; +import type { Logger } from './logger'; +import type { ReadConcern, ReadConcernLevelId, ReadConcernLike } from './read_concern'; import { BSONSerializeOptions, Document, resolveBSONOptions } from './bson'; -import type { AutoEncryptionOptions } from './deps'; -import type { CompressorName } from './cmap/wire_protocol/compression'; +import type { AutoEncrypter, AutoEncryptionOptions } from './deps'; import type { AuthMechanismId } from './cmap/auth/defaultAuthProviders'; import type { Topology } from './sdam/topology'; import type { ClientSession, ClientSessionOptions } from './sessions'; import type { TagSet } from './sdam/server_description'; -import type { ConnectionOptions as TLSConnectionOptions } from 'tls'; -import type { TcpSocketConnectOpts as ConnectionOptions } from 'net'; import type { MongoCredentials } from './cmap/auth/mongo_credentials'; import { parseOptions } from './connection_string'; +import type { CompressorName } from './cmap/wire_protocol/compression'; +import type { TLSSocketOptions, ConnectionOptions as TLSConnectionOptions } from 'tls'; +import type { TcpNetConnectOpts } from 'net'; +import type { SrvPoller } from './sdam/srv_polling'; +import type { Connection } from './cmap/connection'; +import type { LEGAL_TLS_SOCKET_OPTIONS, LEGAL_TCP_SOCKET_OPTIONS } from './cmap/connect'; /** @public */ export const LogLevel = { @@ -55,18 +66,43 @@ export interface PkFactory { type CleanUpHandlerFunction = (err?: AnyError, result?: any, opts?: any) => Promise; +/** @public */ +export type SupportedTLSConnectionOptions = Pick< + TLSConnectionOptions, + Extract +>; + +/** @public */ +export type SupportedTLSSocketOptions = Pick< + TLSSocketOptions, + Extract +>; + +/** @public */ +export type SupportedSocketOptions = Pick< + TcpNetConnectOpts, + typeof LEGAL_TCP_SOCKET_OPTIONS[number] +>; + +/** @public */ +export type SupportedNodeConnectionOptions = SupportedTLSConnectionOptions & + SupportedTLSSocketOptions & + SupportedSocketOptions; + /** * Describes all possible URI query options for the mongo client * @public * @see https://docs.mongodb.com/manual/reference/connection-string */ -export interface MongoURIOptions { +export interface MongoClientOptions extends BSONSerializeOptions, SupportedNodeConnectionOptions { /** Specifies the name of the replica set, if the mongod is a member of a replica set. */ replicaSet?: string; /** Enables or disables TLS/SSL for the connection. */ tls?: boolean; /** A boolean to enable or disables TLS/SSL for the connection. (The ssl option is equivalent to the tls option.) */ - ssl?: MongoURIOptions['tls']; + ssl?: boolean; + /** Specifies the location of a local TLS Certificate */ + tlsCertificateFile?: string; /** Specifies the location of a local .pem file that contains either the client’s TLS/SSL certificate or the client’s TLS/SSL certificate and key. */ tlsCertificateKeyFile?: string; /** Specifies the password to de-crypt the tlsCertificateKeyFile. */ @@ -84,7 +120,7 @@ export interface MongoURIOptions { /** The time in milliseconds to attempt a send or receive on a socket before the attempt times out. */ socketTimeoutMS?: number; /** Comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance. */ - compressors?: string; + compressors?: CompressorName[]; /** An integer that specifies the compression level if using zlib for network compression. */ zlibCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | undefined; /** The maximum number of connections in the connection pool. */ @@ -95,6 +131,8 @@ export interface MongoURIOptions { maxIdleTimeMS?: number; /** The maximum time in milliseconds that a thread can wait for a connection to become available. */ waitQueueTimeoutMS?: number; + /** Specify a read concern for the collection (only MongoDB 3.2 or higher supported) */ + readConcern?: ReadConcernLike; /** The level of isolation */ readConcernLevel?: ReadConcernLevelId; /** Specifies the read preferences for this connection */ @@ -103,6 +141,8 @@ export interface MongoURIOptions { maxStalenessSeconds?: number; /** Specifies the tags document as a comma-separated list of colon-separated key-value pairs. */ readPreferenceTags?: TagSet[]; + /** The auth settings for when connection to server. */ + auth?: Auth; /** Specify the database name associated with the user’s credentials. */ authSource?: string; /** Specify the authentication mechanism that MongoDB will use to authenticate the connection. */ @@ -131,44 +171,31 @@ export interface MongoURIOptions { /** Allow a driver to force a Single topology type with a connection string containing one host */ directConnection?: boolean; - // username and password in Authority section not query string. - username?: string; - password?: string; - - // remove in NODE-2704 - fsync?: boolean; + /** The write concern */ w?: W; - j?: boolean; - journal?: boolean; - wtimeout?: number; + /** The write concern timeout */ wtimeoutMS?: number; - writeConcern?: WriteConcern | WriteConcernSettings; -} + /** The journal write concern */ + journal?: boolean; -/** @public */ -export interface MongoClientOptions extends MongoURIOptions, BSONSerializeOptions { /** Validate mongod server certificate against Certificate Authority */ sslValidate?: boolean; - /** SSL Certificate store binary buffer. */ - sslCA?: string | Buffer | Array; - /** SSL Certificate binary buffer. */ - sslCert?: string | Buffer | Array; - /** SSL Key file binary buffer. */ - sslKey?: string | Buffer | Array; + /** SSL Certificate file path. */ + sslCA?: string; + /** SSL Certificate file path. */ + sslCert?: string; + /** SSL Key file file path. */ + sslKey?: string; /** SSL Certificate pass phrase. */ sslPass?: string; - /** SSL Certificate revocation list binary buffer. */ - sslCRL?: string | Buffer | Array; - /** Ensure we check server identify during SSL, set to false to disable checking. */ - checkServerIdentity?: boolean | ((hostname: string, cert: Document) => Error | undefined); + /** SSL Certificate revocation list file path. */ + sslCRL?: string; /** TCP Connection no delay */ noDelay?: boolean; /** TCP Connection keep alive enabled */ keepAlive?: boolean; /** The number of milliseconds to wait before initiating keepAlive on the TCP socket */ keepAliveInitialDelay?: number; - /** Version of IP stack. Can be 4, 6 or null (default). If null, will attempt to connect with IPv6, and will fall back to IPv4 on failure */ - family?: 4 | 6 | null; /** Force server to assign `_id` values instead of driver */ forceServerObjectId?: boolean; /** Return document results as raw BSON buffers */ @@ -177,29 +204,20 @@ export interface MongoClientOptions extends MongoURIOptions, BSONSerializeOption pkFactory?: PkFactory; /** A Promise library class the application wishes to use such as Bluebird, must be ES6 compatible */ promiseLibrary?: any; - /** Specify a read concern for the collection (only MongoDB 3.2 or higher supported) */ - readConcern?: ReadConcernLike; /** The logging level */ loggerLevel?: LogLevelId; /** Custom logger object */ logger?: Logger; - /** The auth settings for when connection to server. */ - auth?: Auth; - /** Type of compression to use?: snappy or zlib */ - compression?: CompressorName; - /** The number of retries for a tailable cursor */ - numberOfRetries?: number; /** Enable command monitoring for this client */ monitorCommands?: boolean; /** Optionally enable client side auto encryption */ autoEncryption?: AutoEncryptionOptions; /** Allows a wrapping driver to amend the client metadata generated by the driver to include information about the wrapping driver */ driverInfo?: DriverInfo; - /** String containing the server name requested via TLS SNI. */ - servername?: string; - dbName?: string; - useRecoveryToken?: boolean; + useRecoveryToken?: boolean; // legacy? + srvPoller?: SrvPoller; + connectionType?: typeof Connection; } /** @public */ @@ -218,6 +236,8 @@ export interface MongoClientPrivate { logger: Logger; } +const kOptions = Symbol('options'); + /** * The **MongoClient** class is a class that allows for making Connections to MongoDB. * @public @@ -264,7 +284,7 @@ export class MongoClient extends EventEmitter { * The consolidate, parsed, transformed and merged options. * @internal */ - options; + [kOptions]: MongoOptions; // debugging originalUri; @@ -273,31 +293,33 @@ export class MongoClient extends EventEmitter { constructor(url: string, options?: MongoClientOptions) { super(); - if (options && options.promiseLibrary) { - PromiseProvider.set(options.promiseLibrary); - // TODO NODE-2530: this will go away when client options are sorted out - // NOTE: need this to prevent deprecation notice from being inherited in Db, Collection - delete options.promiseLibrary; - } this.originalUri = url; this.originalOptions = options; - this.options = parseOptions(url, options); + this[kOptions] = parseOptions(url, this, options); // The internal state this.s = { url, - options: options ?? {}, + options: this[kOptions], sessions: new Set(), - readConcern: ReadConcern.fromOptions(options), - writeConcern: WriteConcern.fromOptions(options), - readPreference: ReadPreference.fromOptions(options) ?? ReadPreference.primary, - bsonOptions: resolveBSONOptions(options), - namespace: new MongoDBNamespace('admin'), - logger: options?.logger ?? new Logger('MongoClient') + readConcern: this[kOptions].readConcern, + writeConcern: this[kOptions].writeConcern, + readPreference: this[kOptions].readPreference, + bsonOptions: resolveBSONOptions(this[kOptions]), + namespace: ns('admin'), + logger: this[kOptions].logger }; } + get options(): Readonly { + return Object.freeze({ ...this[kOptions] }); + } + + get autoEncrypter(): AutoEncrypter | undefined { + return this[kOptions].autoEncrypter; + } + get readConcern(): ReadConcern | undefined { return this.s.readConcern; } @@ -331,10 +353,7 @@ export class MongoClient extends EventEmitter { } return maybePromise(callback, cb => { - const err = validOptions(this.s.options as any); - if (err) return cb(err); - - connect(this, this.s.url, this.s.options as any, err => { + connect(this, this[kOptions], err => { if (err) return cb(err); cb(undefined, this); }); @@ -388,18 +407,16 @@ export class MongoClient extends EventEmitter { * @param dbName - The name of the database we want to use. If not provided, use database name from connection string. * @param options - Optional settings for Db construction */ - db(dbName: string): Db; - db(dbName: string, options: DbOptions): Db; - db(dbName: string, options?: DbOptions): Db { + db(dbName?: string, options?: DbOptions): Db { options = options ?? {}; // Default to db from connection string if not provided - if (!dbName && this.s.options?.dbName) { - dbName = this.s.options?.dbName; + if (!dbName) { + dbName = this.options.dbName; } // Copy the options and add out internal override of the not shared flag - const finalOptions = Object.assign({}, this.s.options, options); + const finalOptions = Object.assign({}, this[kOptions], options); // If no topology throw an error message if (!this.topology) { @@ -436,10 +453,15 @@ export class MongoClient extends EventEmitter { if (typeof options === 'function') (callback = options), (options = {}); options = options ?? {}; - // Create client - const mongoClient = new MongoClient(url, options); - // Execute the connect method - return mongoClient.connect(callback); + try { + // Create client + const mongoClient = new MongoClient(url, options); + // Execute the connect method + return mongoClient.connect(callback); + } catch (error) { + if (callback) return callback(error); + else return PromiseProvider.get().reject(error); + } } /** Starts a new session on the server */ @@ -549,28 +571,18 @@ export class MongoClient extends EventEmitter { }, 'Multiple authentication is prohibited on a connected client, please only authenticate once per MongoClient'); } -/** @public */ -export type HostAddress = - | { host: string; type: 'srv' } - | { host: string; port: number; type: 'tcp' } - | { host: string; type: 'unix' }; - /** * Mongo Client Options * @public */ export interface MongoOptions - extends Required, - Omit, - Omit, - Required< + extends Required< Pick< MongoClientOptions, | 'autoEncryption' - | 'compression' | 'compressors' + | 'connectionType' | 'connectTimeoutMS' - | 'dbName' | 'directConnection' | 'driverInfo' | 'forceServerObjectId' @@ -585,7 +597,6 @@ export interface MongoOptions | 'minPoolSize' | 'monitorCommands' | 'noDelay' - | 'numberOfRetries' | 'pkFactory' | 'promiseLibrary' | 'raw' @@ -600,13 +611,23 @@ export interface MongoOptions | 'waitQueueTimeoutMS' | 'zlibCompressionLevel' > - > { + >, + SupportedNodeConnectionOptions { hosts: HostAddress[]; - srv: boolean; - credentials: MongoCredentials; + srvHost?: string; + credentials?: MongoCredentials; readPreference: ReadPreference; readConcern: ReadConcern; writeConcern: WriteConcern; + dbName: string; + metadata: ClientMetadata; + autoEncrypter?: AutoEncrypter; + + userSpecifiedAuthSource: boolean; + userSpecifiedReplicaSet: boolean; + + // TODO: remove in v4 + useRecoveryToken: boolean; /** * # NOTE ABOUT TLS Options diff --git a/src/operations/connect.ts b/src/operations/connect.ts index bfd8bc1cb4..dd27abb9ec 100644 --- a/src/operations/connect.ts +++ b/src/operations/connect.ts @@ -1,151 +1,13 @@ -import * as fs from 'fs'; -import { Logger } from '../logger'; -import { ReadPreference } from '../read_preference'; -import { MongoError, AnyError } from '../error'; -import { ServerAddress, Topology, TopologyOptions } from '../sdam/topology'; -import { AUTH_MECHANISMS, parseConnectionString } from '../connection_string'; -import { ReadConcern } from '../read_concern'; +import { MongoError } from '../error'; +import { Topology } from '../sdam/topology'; +import { resolveSRVRecord } from '../connection_string'; import { emitDeprecationWarning, Callback } from '../utils'; import { CMAP_EVENT_NAMES } from '../cmap/events'; -import { MongoCredentials } from '../cmap/auth/mongo_credentials'; import * as BSON from '../bson'; -import type { Document } from '../bson'; -import type { MongoClient } from '../mongo_client'; -import { ConnectionOptions, Connection } from '../cmap/connection'; -import { AuthMechanism, AuthMechanismId } from '../cmap/auth/defaultAuthProviders'; +import type { MongoClient, MongoOptions } from '../mongo_client'; +import { Connection } from '../cmap/connection'; import { Server } from '../sdam/server'; -import { WRITE_CONCERN_KEYS } from '../write_concern'; - -const validOptionNames = [ - 'poolSize', - 'ssl', - 'sslValidate', - 'sslCA', - 'sslCert', - 'sslKey', - 'sslPass', - 'sslCRL', - 'autoReconnect', - 'noDelay', - 'keepAlive', - 'keepAliveInitialDelay', - 'connectTimeoutMS', - 'family', - 'socketTimeoutMS', - 'reconnectTries', - 'reconnectInterval', - 'ha', - 'haInterval', - 'replicaSet', - 'secondaryAcceptableLatencyMS', - 'acceptableLatencyMS', - 'connectWithNoPrimary', - 'authSource', - 'writeConcern', - 'forceServerObjectId', - 'serializeFunctions', - 'ignoreUndefined', - 'raw', - 'readPreference', - 'pkFactory', - 'promiseLibrary', - 'readConcern', - 'maxStalenessSeconds', - 'loggerLevel', - 'logger', - 'promoteValues', - 'promoteBuffers', - 'promoteLongs', - 'domainsEnabled', - 'checkServerIdentity', - 'validateOptions', - 'appname', - 'auth', - 'user', - 'username', - 'host', - 'password', - 'authMechanism', - 'compression', - 'readPreferenceTags', - 'numberOfRetries', - 'auto_reconnect', - 'minSize', - 'monitorCommands', - 'retryWrites', - 'retryReads', - 'useNewUrlParser', - 'serverSelectionTimeoutMS', - 'useRecoveryToken', - 'autoEncryption', - 'driverInfo', - 'tls', - 'tlsInsecure', - 'tlsAllowInvalidCertificates', - 'tlsAllowInvalidHostnames', - 'tlsDisableCertificateRevocationCheck', - 'tlsDisableOCSPEndpointCheck', - 'tlsCAFile', - 'tlsCertificateFile', - 'tlsCertificateKeyFile', - 'tlsCertificateKeyFilePassword', - 'minHeartbeatFrequencyMS', - 'heartbeatFrequencyMS', - 'directConnection', - 'appName', - - // CMAP options - 'maxPoolSize', - 'minPoolSize', - 'maxIdleTimeMS', - 'waitQueueTimeoutMS' -]; - -const ignoreOptionNames = ['native_parser']; -const legacyOptionNames = ['server', 'replset', 'replSet', 'mongos', 'db']; - -interface MongoClientOptions extends TopologyOptions, Omit { - tls: boolean; - servers: string | ServerAddress[]; - autoEncryption: null; - validateOptions: boolean; - readPreference?: ReadPreference; - - sslCA: string | Buffer; - sslKey: string | Buffer; - sslCert: string | Buffer; -} - -// Validate options object -export function validOptions(options?: MongoClientOptions): void | MongoError { - const _validOptions = validOptionNames.concat(legacyOptionNames); - - for (const name in options) { - if (ignoreOptionNames.indexOf(name) !== -1) { - continue; - } - - if (_validOptions.indexOf(name) === -1) { - if (options.validateOptions) { - return new MongoError(`option ${name} is not supported`); - } else { - console.warn(`the options [${name}] is not supported`); - } - } - - if (legacyOptionNames.indexOf(name) !== -1) { - console.warn( - `the server/replset/mongos/db options are deprecated, ` + - `all their options are supported at the top level of the options object [${validOptionNames}]` - ); - } - } -} - -const LEGACY_OPTIONS_MAP = validOptionNames.reduce((obj, name: string) => { - obj[name.toLowerCase()] = name; - return obj; -}, {} as { [key: string]: string }); +import type { AutoEncrypter } from '../deps'; function addListeners(mongoClient: MongoClient, topology: Topology) { topology.on('authenticated', createListener(mongoClient, 'authenticated')); @@ -159,28 +21,11 @@ function addListeners(mongoClient: MongoClient, topology: Topology) { topology.on('reconnect', createListener(mongoClient, 'reconnect')); } -function resolveTLSOptions(options: MongoClientOptions) { - if (!options.tls) { - return; - } - - const keyFileOptionNames = ['sslCA', 'sslKey', 'sslCert'] as const; - for (const optionName of keyFileOptionNames) { - if (options[optionName]) { - options[optionName] = fs.readFileSync(options[optionName]); - } - } -} - export function connect( mongoClient: MongoClient, - url: string, - options: ConnectionOptions, + options: MongoOptions, callback: Callback ): void { - options = Object.assign({}, options); - - // If callback is null throw an exception if (!callback) { throw new Error('no callback function provided'); } @@ -190,59 +35,9 @@ export function connect( return callback(undefined, mongoClient); } - let didRequestAuthentication = false; - const logger = new Logger('MongoClient', options); - - parseConnectionString(url, options, (err, connectionStringOptions) => { - // Do not attempt to connect if parsing error - if (err) return callback(err); - - // Flatten - const urlOptions = transformUrlOptions(connectionStringOptions); - - // Parse the string - const finalOptions = createUnifiedOptions(urlOptions, options); - - // Check if we have connection and socket timeout set - if (finalOptions.socketTimeoutMS == null) finalOptions.socketTimeoutMS = 0; - if (finalOptions.connectTimeoutMS == null) finalOptions.connectTimeoutMS = 10000; - if (finalOptions.retryWrites == null) finalOptions.retryWrites = true; - if (finalOptions.useRecoveryToken == null) finalOptions.useRecoveryToken = true; - if (finalOptions.readPreference == null) finalOptions.readPreference = 'primary'; - - if (finalOptions.db_options && finalOptions.db_options.auth) { - delete finalOptions.db_options.auth; - } - - // resolve tls options if needed - resolveTLSOptions(finalOptions); - - // Store the merged options object - mongoClient.s.options = finalOptions; - - // Failure modes - if (urlOptions.servers.length === 0) { - return callback(new Error('connection string must contain at least one seed host')); - } - - if (finalOptions.auth && !finalOptions.credentials) { - try { - didRequestAuthentication = true; - finalOptions.credentials = generateCredentials( - mongoClient, - finalOptions.auth.user, - finalOptions.auth.password, - finalOptions - ); - } catch (err) { - return callback(err); - } - } - - return createTopology(mongoClient, finalOptions, connectCallback); - }); - - function connectCallback(err?: AnyError, topology?: MongoClient) { + const didRequestAuthentication = false; + const logger = mongoClient.logger; + const connectCallback: Callback = err => { const warningMessage = 'seed list contains no mongos proxies, replicaset connections requires ' + 'the parameter replicaSet to be supplied in the URI or options object, ' + @@ -260,9 +55,21 @@ export function connect( mongoClient.emit('authenticated', null, true); } - // Return the error and db instance - callback(err, topology); + callback(err, mongoClient); + }; + + if (typeof options.srvHost === 'string') { + return resolveSRVRecord(options, (err, hosts) => { + if (err || !hosts) return callback(err); + for (const [index, host] of hosts.entries()) { + options.hosts[index] = host; + } + + return createTopology(mongoClient, options, connectCallback); + }); } + + return createTopology(mongoClient, options, connectCallback); } export type ListenerFunction = (v1: V1, v2: V2) => boolean; @@ -302,49 +109,50 @@ function registerDeprecatedEventNotifiers(client: MongoClient) { }); } -function createTopology(mongoClient: MongoClient, options: MongoClientOptions, callback: Callback) { - // Set default options - translateOptions(options); - - // determine CSFLE support - if (options.autoEncryption != null) { - let AutoEncrypter; - try { - require.resolve('mongodb-client-encryption'); - } catch (err) { - callback( - new MongoError( - 'Auto-encryption requested, but the module is not installed. ' + - 'Please add `mongodb-client-encryption` as a dependency of your project' - ) - ); - return; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const mongodbClientEncryption = require('mongodb-client-encryption'); - if (typeof mongodbClientEncryption.extension !== 'function') { - callback( - new MongoError( - 'loaded version of `mongodb-client-encryption` does not have property `extension`. ' + - 'Please make sure you are loading the correct version of `mongodb-client-encryption`' - ) - ); - } - // eslint-disable-next-line @typescript-eslint/no-var-requires - AutoEncrypter = mongodbClientEncryption.extension(require('../../lib/index')).AutoEncrypter; - } catch (err) { - callback(err); - return; - } +/** + * If AutoEncryption is requested, handles the optional dependency logic and passing through options + * returns undefined if CSFLE is not enabled. + * @throws if optional 'mongodb-client-encryption' dependency missing + */ +export function createAutoEncrypter(client: MongoClient): AutoEncrypter | undefined { + if (!client.options.autoEncryption) { + return; + } + try { + require.resolve('mongodb-client-encryption'); + } catch (err) { + throw new MongoError( + 'Auto-encryption requested, but the module is not installed. ' + + 'Please add `mongodb-client-encryption` as a dependency of your project' + ); + } - const mongoCryptOptions = Object.assign({ bson: BSON }, options.autoEncryption); - options.autoEncrypter = new AutoEncrypter(mongoClient, mongoCryptOptions); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mongodbClientEncryption = require('mongodb-client-encryption'); + if (typeof mongodbClientEncryption.extension !== 'function') { + throw new MongoError( + 'loaded version of `mongodb-client-encryption` does not have property `extension`. ' + + 'Please make sure you are loading the correct version of `mongodb-client-encryption`' + ); } + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { AutoEncrypterClass } = mongodbClientEncryption.extension(require('../../lib/index')); + + const mongoCryptOptions = Object.assign({ bson: BSON }, client.options.autoEncryption); + return new AutoEncrypterClass(client, mongoCryptOptions); +} +function createTopology( + mongoClient: MongoClient, + options: MongoOptions, + callback: Callback +) { // Create the topology - const topology = new Topology(options.servers, options); + const topology = new Topology(options.hosts, options); + // Events can be emitted before initialization is complete so we have to + // save the reference to the topology on the client ASAP if the event handlers need to access it + mongoClient.topology = topology; + registerDeprecatedEventNotifiers(mongoClient); // Add listeners @@ -353,12 +161,9 @@ function createTopology(mongoClient: MongoClient, options: MongoClientOptions, c // Propagate the events to the client relayEvents(mongoClient, topology); - // Assign the topology - mongoClient.topology = topology; - // initialize CSFLE if requested - if (options.autoEncrypter) { - options.autoEncrypter.init(err => { + if (mongoClient.autoEncrypter) { + mongoClient.autoEncrypter.init(err => { if (err) { callback(err); return; @@ -390,93 +195,6 @@ function createTopology(mongoClient: MongoClient, options: MongoClientOptions, c }); } -function createUnifiedOptions(finalOptions: any, options: any) { - const childOptions = [ - 'mongos', - 'server', - 'db', - 'replset', - 'db_options', - 'server_options', - 'rs_options', - 'mongos_options' - ]; - const noMerge = ['readconcern', 'compression', 'autoencryption']; - - for (const name in options) { - if (name === 'writeConcern') { - finalOptions[name] = { ...finalOptions[name], ...options[name] }; - } else if (noMerge.indexOf(name.toLowerCase()) !== -1) { - finalOptions[name] = options[name]; - } else if (childOptions.indexOf(name.toLowerCase()) !== -1) { - finalOptions = mergeOptions(finalOptions, options[name], false); - } else { - if ( - options[name] && - typeof options[name] === 'object' && - !Buffer.isBuffer(options[name]) && - !Array.isArray(options[name]) - ) { - finalOptions = mergeOptions(finalOptions, options[name], true); - } else { - finalOptions[name] = options[name]; - } - } - } - - return finalOptions; -} - -export interface GenerateCredentialsOptions { - authSource: string; - authdb: string; - dbName: string; - authMechanism: AuthMechanismId; - authMechanismProperties: Document; -} - -function generateCredentials( - client: MongoClient, - username: string, - password: string, - options: GenerateCredentialsOptions -) { - options = Object.assign({}, options); - - // the default db to authenticate against is 'self' - // if authenticate is called from a retry context, it may be another one, like admin - const source = options.authSource || options.authdb || options.dbName; - - // authMechanism - const authMechanismRaw = options.authMechanism || AuthMechanism.MONGODB_DEFAULT; - const mechanism = authMechanismRaw.toUpperCase() as AuthMechanismId; - const mechanismProperties = options.authMechanismProperties; - - if (!AUTH_MECHANISMS.has(mechanism)) { - throw new MongoError(`authentication mechanism ${mechanism} not supported`); - } - - return new MongoCredentials({ - mechanism, - mechanismProperties, - source, - username, - password - }); -} - -function mergeOptions(target: T, source: S, flatten: boolean): S & T { - for (const name in source) { - if (source[name] && typeof source[name] === 'object' && flatten) { - target = mergeOptions(target, source[name], flatten); - } else { - target = Object.assign(target, { [name]: source[name] }); - } - } - - return target as S & T; -} - function relayEvents(mongoClient: MongoClient, topology: Topology) { const serverOrCommandEvents = [ // APM @@ -508,106 +226,3 @@ function relayEvents(mongoClient: MongoClient, topology: Topology) { }); }); } - -function transformUrlOptions(connStrOptions: any) { - const connStrOpts = Object.assign({ servers: connStrOptions.hosts }, connStrOptions.options); - for (const name in connStrOpts) { - const camelCaseName = LEGACY_OPTIONS_MAP[name]; - if (camelCaseName) { - connStrOpts[camelCaseName] = connStrOpts[name]; - } - } - - const hasUsername = connStrOptions.auth && connStrOptions.auth.username; - const hasAuthMechanism = connStrOptions.options && connStrOptions.options.authMechanism; - if (hasUsername || hasAuthMechanism) { - connStrOpts.auth = Object.assign({}, connStrOptions.auth); - if (connStrOpts.auth.db) { - connStrOpts.authSource = connStrOpts.authSource || connStrOpts.auth.db; - } - - if (connStrOpts.auth.username) { - connStrOpts.auth.user = connStrOpts.auth.username; - } - } - - if (connStrOptions.defaultDatabase) { - connStrOpts.dbName = connStrOptions.defaultDatabase; - } - - if (connStrOpts.maxPoolSize) { - connStrOpts.poolSize = connStrOpts.maxPoolSize; - } - - if (connStrOpts.readConcernLevel) { - connStrOpts.readConcern = new ReadConcern(connStrOpts.readConcernLevel); - } - - if (connStrOpts.wTimeoutMS) { - connStrOpts.wtimeout = connStrOpts.wTimeoutMS; - connStrOpts.wTimeoutMS = undefined; - } - - if (connStrOptions.srvHost) { - connStrOpts.srvHost = connStrOptions.srvHost; - } - - // Any write concern options from the URL will be top-level, so we manually - // move them options under `object.writeConcern` - for (const key of WRITE_CONCERN_KEYS) { - if (connStrOpts[key] !== undefined) { - if (connStrOpts.writeConcern === undefined) connStrOpts.writeConcern = {}; - connStrOpts.writeConcern[key] = connStrOpts[key]; - connStrOpts[key] = undefined; - } - } - - return connStrOpts; -} - -function translateOptions(options: any) { - // If we have a readPreference passed in by the db options - if (typeof options.readPreference === 'string' || typeof options.read_preference === 'string') { - options.readPreference = new ReadPreference(options.readPreference || options.read_preference); - } - - // Do we have readPreference tags, add them - if (options.readPreference && (options.readPreferenceTags || options.read_preference_tags)) { - options.readPreference.tags = options.readPreferenceTags || options.read_preference_tags; - } - - // Do we have maxStalenessSeconds - if (options.maxStalenessSeconds) { - options.readPreference.maxStalenessSeconds = options.maxStalenessSeconds; - } - - // Set the socket and connection timeouts - if (options.socketTimeoutMS == null) options.socketTimeoutMS = 0; - if (options.connectTimeoutMS == null) options.connectTimeoutMS = 10000; - - const translations = { - // SSL translation options - sslCA: 'ca', - sslCRL: 'crl', - sslValidate: 'rejectUnauthorized', - sslKey: 'key', - sslCert: 'cert', - sslPass: 'passphrase', - // SocketTimeout translation options - socketTimeoutMS: 'socketTimeout', - connectTimeoutMS: 'connectionTimeout', - // Replicaset options - replicaSet: 'setName', - rs_name: 'setName', - secondaryAcceptableLatencyMS: 'acceptableLatency', - connectWithNoPrimary: 'secondaryOnlyConnectionAllowed', - // Mongos options - acceptableLatencyMS: 'localThresholdMS' - } as { [key: string]: string }; - - for (const name in options) { - if (translations[name]) { - options[translations[name]] = options[name]; - } - } -} diff --git a/src/read_preference.ts b/src/read_preference.ts index 7a71a18dad..44873cba65 100644 --- a/src/read_preference.ts +++ b/src/read_preference.ts @@ -155,13 +155,21 @@ export class ReadPreference { } else if (!(readPreference instanceof ReadPreference) && typeof readPreference === 'object') { const mode = readPreference.mode || readPreference.preference; if (mode && typeof mode === 'string') { - return new ReadPreference(mode as ReadPreferenceModeId, readPreference.tags, { - maxStalenessSeconds: readPreference.maxStalenessSeconds, - hedge: options.hedge - }); + return new ReadPreference( + mode as ReadPreferenceModeId, + readPreference.tags ?? readPreferenceTags, + { + maxStalenessSeconds: readPreference.maxStalenessSeconds, + hedge: options.hedge + } + ); } } + if (readPreferenceTags) { + readPreference.tags = readPreferenceTags; + } + return readPreference as ReadPreference; } diff --git a/src/sdam/common.ts b/src/sdam/common.ts index 7dbf552c6d..490706678d 100644 --- a/src/sdam/common.ts +++ b/src/sdam/common.ts @@ -36,16 +36,6 @@ export enum ServerType { Unknown = 'Unknown' } -export const TOPOLOGY_DEFAULTS = { - localThresholdMS: 15, - serverSelectionTimeoutMS: 30000, - heartbeatFrequencyMS: 10000, - minHeartbeatFrequencyMS: 500, - - // TODO: remove in v4 - useRecoveryToken: true -}; - /** @internal */ export type TimerQueue = Set; diff --git a/src/sdam/monitor.ts b/src/sdam/monitor.ts index cd56bf7774..98a7786047 100644 --- a/src/sdam/monitor.ts +++ b/src/sdam/monitor.ts @@ -8,7 +8,7 @@ import { } from '../utils'; import { EventEmitter } from 'events'; import { connect } from '../cmap/connect'; -import { Connection } from '../cmap/connection'; +import { Connection, ConnectionOptions } from '../cmap/connection'; import { MongoNetworkError, AnyError } from '../error'; import { Long, Document } from '../bson'; import { @@ -20,7 +20,6 @@ import { import { Server } from './server'; import type { InterruptibleAsyncInterval, Callback } from '../utils'; import type { TopologyVersion } from './server_description'; -import type { ConnectionOptions } from '../cmap/connection'; const kServer = Symbol('server'); const kMonitorId = Symbol('monitorId'); @@ -49,7 +48,8 @@ export interface MonitorPrivate { } /** @public */ -export interface MonitorOptions { +export interface MonitorOptions + extends Omit { connectTimeoutMS: number; heartbeatFrequencyMS: number; minHeartbeatFrequencyMS: number; @@ -60,7 +60,9 @@ export class Monitor extends EventEmitter { /** @internal */ s: MonitorPrivate; address: string; - options: MonitorOptions; + options: Readonly< + Pick + >; connectOptions: ConnectionOptions; [kServer]: Server; [kConnection]?: Connection; @@ -69,7 +71,7 @@ export class Monitor extends EventEmitter { [kMonitorId]?: InterruptibleAsyncInterval; [kRTTPinger]?: RTTPinger; - constructor(server: Server, options?: Partial) { + constructor(server: Server, options: MonitorOptions) { super(); this[kServer] = server; @@ -83,25 +85,22 @@ export class Monitor extends EventEmitter { this.address = server.description.address; this.options = Object.freeze({ - connectTimeoutMS: - typeof options?.connectTimeoutMS === 'number' ? options.connectTimeoutMS : 10000, - heartbeatFrequencyMS: - typeof options?.heartbeatFrequencyMS === 'number' ? options.heartbeatFrequencyMS : 10000, - minHeartbeatFrequencyMS: - typeof options?.minHeartbeatFrequencyMS === 'number' ? options.minHeartbeatFrequencyMS : 500 + connectTimeoutMS: options.connectTimeoutMS ?? 10000, + heartbeatFrequencyMS: options.heartbeatFrequencyMS ?? 10000, + minHeartbeatFrequencyMS: options.minHeartbeatFrequencyMS ?? 500 }); + const cancellationToken = this[kCancellationToken]; // TODO: refactor this to pull it directly from the pool, requires new ConnectionPool integration const connectOptions = Object.assign( { - id: '', - host: server.description.host, - port: server.description.port, - connectionType: Connection + id: '' as const, + generation: server.s.pool.generation, + connectionType: Connection, + cancellationToken, + hostAddress: server.description.hostAddress }, - server.s.options, - this.options, - + options, // force BSON serialization options { raw: false, @@ -273,7 +272,7 @@ function checkServer(monitor: Monitor, callback: Callback) { } // connecting does an implicit `ismaster` - connect(monitor.connectOptions, monitor[kCancellationToken], (err, conn) => { + connect(monitor.connectOptions, (err, conn) => { if (err) { monitor[kConnection] = undefined; @@ -395,7 +394,7 @@ export class RTTPinger { function measureRoundTripTime(rttPinger: RTTPinger, options: RTTPingerOptions) { const start = now(); - const cancellationToken = rttPinger[kCancellationToken]; + options.cancellationToken = rttPinger[kCancellationToken]; const heartbeatFrequencyMS = options.heartbeatFrequencyMS; if (rttPinger.closed) { @@ -421,7 +420,7 @@ function measureRoundTripTime(rttPinger: RTTPinger, options: RTTPingerOptions) { const connection = rttPinger[kConnection]; if (connection == null) { - connect(options, cancellationToken, (err, conn) => { + connect(options, (err, conn) => { if (err) { rttPinger[kConnection] = undefined; rttPinger[kRoundTripTime] = 0; diff --git a/src/sdam/server.ts b/src/sdam/server.ts index bd4c727984..d72dc6d619 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -3,7 +3,7 @@ import { Logger } from '../logger'; import { ConnectionPool, ConnectionPoolOptions } from '../cmap/connection_pool'; import { CMAP_EVENT_NAMES } from '../cmap/events'; import { ServerDescription, compareTopologyVersion } from './server_description'; -import { Monitor } from './monitor'; +import { Monitor, MonitorOptions } from './monitor'; import { isTransactionCommand } from '../transactions'; import { relayEvents, @@ -11,7 +11,6 @@ import { debugOptions, makeStateMachine, maxWireVersion, - ClientMetadataOptions, Callback, CallbackWithType, MongoDBNamespace @@ -41,7 +40,6 @@ import { CommandOptions } from '../cmap/connection'; import type { Topology } from './topology'; -import type { MongoCredentials } from '../cmap/auth/mongo_credentials'; import type { ServerHeartbeatSucceededEvent } from './events'; import type { ClientSession } from '../sessions'; import type { Document, Long } from '../bson'; @@ -85,16 +83,15 @@ const stateTransition = makeStateMachine({ const kMonitor = Symbol('monitor'); /** @public */ -export interface ServerOptions extends ConnectionPoolOptions, ClientMetadataOptions { - credentials?: MongoCredentials; -} +export type ServerOptions = Omit & + MonitorOptions; /** @internal */ export interface ServerPrivate { /** The server description for this server */ description: ServerDescription; /** A copy of the options used to construct this instance */ - options?: ServerOptions; + options: ServerOptions; /** A logger instance */ logger: Logger; /** The current state of the Server */ @@ -131,16 +128,18 @@ export class Server extends EventEmitter { /** * Create a server */ - constructor(topology: Topology, description: ServerDescription, options?: ServerOptions) { + constructor(topology: Topology, description: ServerDescription, options: ServerOptions) { super(); + const poolOptions = { hostAddress: description.hostAddress, ...options }; + this.s = { description, options, - logger: new Logger('Server', options), + logger: new Logger('Server'), state: STATE_CLOSED, topology, - pool: new ConnectionPool({ host: description.host, port: description.port, ...options }) + pool: new ConnectionPool(poolOptions) }; relayEvents( @@ -172,7 +171,7 @@ export class Server extends EventEmitter { this[kMonitor].on(Server.SERVER_HEARTBEAT_SUCCEEDED, (event: ServerHeartbeatSucceededEvent) => { this.emit( Server.DESCRIPTION_RECEIVED, - new ServerDescription(this.description.address, event.reply, { + new ServerDescription(this.description.hostAddress, event.reply, { roundTripTime: calculateRoundTripTime(this.description.roundTripTime, event.duration) }) ); @@ -430,7 +429,7 @@ function markServerUnknown(server: Server, error?: MongoError) { server.emit( Server.DESCRIPTION_RECEIVED, - new ServerDescription(server.description.address, undefined, { + new ServerDescription(server.description.hostAddress, undefined, { error, topologyVersion: error && error.topologyVersion ? error.topologyVersion : server.description.topologyVersion diff --git a/src/sdam/server_description.ts b/src/sdam/server_description.ts index 3c5d275ad8..b98086b435 100644 --- a/src/sdam/server_description.ts +++ b/src/sdam/server_description.ts @@ -1,6 +1,5 @@ -import { arrayStrictEqual, errorStrictEqual } from '../utils'; +import { arrayStrictEqual, errorStrictEqual, now, HostAddress } from '../utils'; import { ServerType } from './common'; -import { now } from '../utils'; import type { ObjectId, Long, Document } from '../bson'; import type { ClusterTime } from './common'; @@ -45,6 +44,7 @@ export interface ServerDescriptionOptions { * @public */ export class ServerDescription { + private _hostAddress: HostAddress; address: string; type: ServerType; hosts: string[]; @@ -77,8 +77,18 @@ export class ServerDescription { * @param address - The address of the server * @param ismaster - An optional ismaster response for this server */ - constructor(address: string, ismaster?: Document, options?: ServerDescriptionOptions) { - this.address = address; + constructor( + address: HostAddress | string, + ismaster?: Document, + options?: ServerDescriptionOptions + ) { + if (typeof address === 'string') { + this._hostAddress = new HostAddress(address); + this.address = this._hostAddress.toString(); + } else { + this._hostAddress = address; + this.address = this._hostAddress.toString(); + } this.type = parseServerType(ismaster); this.hosts = ismaster?.hosts?.map((host: string) => host.toLowerCase()) ?? []; this.passives = ismaster?.passives?.map((host: string) => host.toLowerCase()) ?? []; @@ -129,6 +139,11 @@ export class ServerDescription { } } + get hostAddress(): HostAddress { + if (this._hostAddress) return this._hostAddress; + else return new HostAddress(this.address); + } + get allHosts(): string[] { return this.hosts.concat(this.arbiters).concat(this.passives); } diff --git a/src/sdam/srv_polling.ts b/src/sdam/srv_polling.ts index e653b5affb..cd4b4cf53d 100644 --- a/src/sdam/srv_polling.ts +++ b/src/sdam/srv_polling.ts @@ -1,6 +1,7 @@ import * as dns from 'dns'; import { Logger, LoggerOptions } from '../logger'; import { EventEmitter } from 'events'; +import { HostAddress } from '../utils'; /** * Determines whether a provided address matches the provided parent domain in order @@ -27,8 +28,13 @@ export class SrvPollingEvent { this.srvRecords = srvRecords; } - addresses(): Set { - return new Set(this.srvRecords.map((record: dns.SrvRecord) => `${record.name}:${record.port}`)); + addresses(): Map { + return new Map( + this.srvRecords.map(record => { + const host = new HostAddress(`${record.name}:${record.port}`); + return [host.toString(), host]; + }) + ); } } diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 30a3beae67..9f91d60f65 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -19,10 +19,9 @@ import { relayEvents, makeStateMachine, eachAsync, - makeClientMetadata, - emitDeprecatedOptionWarning, ClientMetadata, Callback, + HostAddress, ns } from '../utils'; import { @@ -36,8 +35,7 @@ import { STATE_CLOSED, STATE_CLOSING, STATE_CONNECTING, - STATE_CONNECTED, - TOPOLOGY_DEFAULTS + STATE_CONNECTED } from './common'; import { ServerOpeningEvent, @@ -51,9 +49,9 @@ import type { Document, BSONSerializeOptions } from '../bson'; import type { MongoCredentials } from '../cmap/auth/mongo_credentials'; import type { Transaction } from '../transactions'; import type { CloseOptions } from '../cmap/connection_pool'; -import type { LoggerOptions } from '../logger'; import { DestroyOptions, Connection } from '../cmap/connection'; import type { MongoClientOptions } from '../mongo_client'; +import { DEFAULT_OPTIONS } from '../connection_string'; // Global state let globalTopologyCounter = 0; @@ -103,7 +101,7 @@ export interface TopologyPrivate { /** passed in options */ options: TopologyOptions; /** initial seedlist of servers to connect to */ - seedlist: ServerAddress[]; + seedlist: HostAddress[]; /** initial state */ state: string; /** the topology description */ @@ -129,32 +127,18 @@ export interface TopologyPrivate { } /** @public */ -export interface ServerAddress { - host: string; - port: number; - domain_socket?: string; -} - -/** @public */ -export interface TopologyOptions extends ServerOptions, BSONSerializeOptions, LoggerOptions { - reconnect: boolean; - retryWrites?: boolean; - retryReads?: boolean; - host: string; - port?: number; - credentials?: MongoCredentials; +export interface TopologyOptions extends BSONSerializeOptions, ServerOptions { + hosts: HostAddress[]; + retryWrites: boolean; + retryReads: boolean; /** How long to block for server selection before throwing an error */ serverSelectionTimeoutMS: number; - /** The frequency with which topology updates are scheduled */ - heartbeatFrequencyMS: number; - minHeartbeatFrequencyMS: number; /** The name of the replica set to connect to */ replicaSet?: string; srvHost?: string; srvPoller?: SrvPoller; /** Indicates that a client should directly connect to a node without attempting to discover its topology type */ directConnection: boolean; - metadata: ClientMetadata; useRecoveryToken: boolean; } @@ -206,38 +190,56 @@ export class Topology extends EventEmitter { static readonly CONNECT = 'connect' as const; /** - * @param seedlist - a string list, or array of ServerAddress instances to connect to + * @param seedlist - a list of HostAddress instances to connect to */ - constructor(seedlist: string | ServerAddress[], options?: TopologyOptions) { + constructor(seeds: string | string[] | HostAddress | HostAddress[], options: TopologyOptions) { super(); - emitDeprecatedOptionWarning(options, ['promiseLibrary']); - seedlist = seedlist || []; - if (typeof seedlist === 'string') { - seedlist = parseStringSeedlist(seedlist); - } else if (!Array.isArray(seedlist)) { - seedlist = [seedlist]; + // Options should only be undefined in tests, MongoClient will always have defined options + options = options ?? { + hosts: [HostAddress.fromString('localhost:27017')], + retryReads: DEFAULT_OPTIONS.get('retryReads'), + retryWrites: DEFAULT_OPTIONS.get('retryWrites'), + serverSelectionTimeoutMS: DEFAULT_OPTIONS.get('serverSelectionTimeoutMS'), + directConnection: DEFAULT_OPTIONS.get('directConnection'), + metadata: DEFAULT_OPTIONS.get('metadata'), + useRecoveryToken: DEFAULT_OPTIONS.get('useRecoveryToken'), + monitorCommands: DEFAULT_OPTIONS.get('monitorCommands'), + tls: DEFAULT_OPTIONS.get('tls'), + maxPoolSize: DEFAULT_OPTIONS.get('maxPoolSize'), + minPoolSize: DEFAULT_OPTIONS.get('minPoolSize'), + waitQueueTimeoutMS: DEFAULT_OPTIONS.get('waitQueueTimeoutMS'), + connectionType: DEFAULT_OPTIONS.get('connectionType'), + connectTimeoutMS: DEFAULT_OPTIONS.get('connectTimeoutMS'), + maxIdleTimeMS: DEFAULT_OPTIONS.get('maxIdleTimeMS'), + heartbeatFrequencyMS: DEFAULT_OPTIONS.get('heartbeatFrequencyMS'), + minHeartbeatFrequencyMS: DEFAULT_OPTIONS.get('minHeartbeatFrequencyMS') + }; + + if (typeof seeds === 'string') { + seeds = [HostAddress.fromString(seeds)]; + } else if (!Array.isArray(seeds)) { + seeds = [seeds]; } - options = Object.assign({}, TOPOLOGY_DEFAULTS, options); - options = Object.freeze( - Object.assign(options, { - metadata: makeClientMetadata(options), - compression: { compressors: makeCompressionInfo(options) } - }) - ); + const seedlist: HostAddress[] = []; + for (const seed of seeds) { + if (typeof seed === 'string') { + seedlist.push(HostAddress.fromString(seed)); + } else if (seed instanceof HostAddress) { + seedlist.push(seed); + } else { + throw new Error(`Topology cannot be constructed from ${JSON.stringify(seed)}`); + } + } const topologyType = topologyTypeFromOptions(options); const topologyId = globalTopologyCounter++; - const serverDescriptions = seedlist.reduce( - (result: Map, seed: ServerAddress) => { - if (seed.domain_socket) seed.host = seed.domain_socket; - const address = seed.port ? `${seed.host}:${seed.port}` : `${seed.host}:27017`; - result.set(address, new ServerDescription(address)); - return result; - }, - new Map() - ); + + const serverDescriptions = new Map(); + for (const hostAddress of seedlist) { + serverDescriptions.set(hostAddress.toString(), new ServerDescription(hostAddress)); + } this[kWaitQueue] = new Denque(); this.s = { @@ -345,8 +347,7 @@ export class Topology extends EventEmitter { // connect all known servers, then attempt server selection to connect connectServers(this, Array.from(this.s.description.servers.values())); - ReadPreference.translate(options); - const readPreference = options.readPreference || ReadPreference.primary; + const readPreference = options.readPreference ?? ReadPreference.primary; this.selectServer(readPreferenceServerSelector(readPreference), options, (err, server) => { if (err) { this.close(); @@ -746,25 +747,13 @@ function destroyServer( }); } -/** - * Parses a basic seedlist in string form - * - * @param seedlist - The seedlist to parse - */ -function parseStringSeedlist(seedlist: string): ServerAddress[] { - return seedlist.split(',').map((seed: string) => ({ - host: seed.split(':')[0], - port: parseInt(seed.split(':')[1], 10) || 27017 - })); -} - /** Predicts the TopologyType from options */ -function topologyTypeFromOptions(options: TopologyOptions) { - if (options.directConnection) { +function topologyTypeFromOptions(options?: TopologyOptions) { + if (options?.directConnection) { return TopologyType.Single; } - if (options.replicaSet) { + if (options?.replicaSet) { return TopologyType.ReplicaSetNoPrimary; } @@ -965,23 +954,12 @@ function processWaitQueue(topology: Topology) { if (topology[kWaitQueue].length > 0) { // ensure all server monitors attempt monitoring soon - topology.s.servers.forEach((server: Server) => process.nextTick(() => server.requestCheck())); - } -} - -function makeCompressionInfo(options: TopologyOptions) { - if (!options.compression || !options.compression.compressors) { - return []; - } - - // Check that all supplied compressors are valid - options.compression.compressors.forEach((compressor: string) => { - if (compressor !== 'snappy' && compressor !== 'zlib') { - throw new Error('compressors must be at least one of snappy or zlib'); + for (const [, server] of topology.s.servers) { + process.nextTick(function scheduleServerCheck() { + return server.requestCheck(); + }); } - }); - - return options.compression.compressors; + } } /** @public */ diff --git a/src/sdam/topology_description.ts b/src/sdam/topology_description.ts index de95b94237..02f92eada4 100644 --- a/src/sdam/topology_description.ts +++ b/src/sdam/topology_description.ts @@ -51,8 +51,8 @@ export class TopologyDescription { // TODO: consider assigning all these values to a temporary value `s` which // we use `Object.freeze` on, ensuring the internal state of this type // is immutable. - this.type = topologyType || TopologyType.Unknown; - this.servers = serverDescriptions || new Map(); + this.type = topologyType ?? TopologyType.Unknown; + this.servers = serverDescriptions ?? new Map(); this.stale = false; this.compatible = true; this.heartbeatFrequencyMS = options.heartbeatFrequencyMS ?? 0; @@ -127,8 +127,8 @@ export class TopologyDescription { return this; } - for (const address of newAddresses) { - serverDescriptions.set(address, new ServerDescription(address)); + for (const [address, host] of newAddresses) { + serverDescriptions.set(address, new ServerDescription(host)); } return new TopologyDescription( @@ -217,15 +217,16 @@ export class TopologyDescription { maxElectionId ); - (topologyType = result[0]), - (setName = result[1]), - (maxSetVersion = result[2]), - (maxElectionId = result[3]); + topologyType = result[0]; + setName = result[1]; + maxSetVersion = result[2]; + maxElectionId = result[3]; } else if ( [ServerType.RSSecondary, ServerType.RSArbiter, ServerType.RSOther].indexOf(serverType) >= 0 ) { const result = updateRsNoPrimaryFromMember(serverDescriptions, serverDescription, setName); - (topologyType = result[0]), (setName = result[1]); + topologyType = result[0]; + setName = result[1]; } } @@ -242,10 +243,10 @@ export class TopologyDescription { maxElectionId ); - (topologyType = result[0]), - (setName = result[1]), - (maxSetVersion = result[2]), - (maxElectionId = result[3]); + topologyType = result[0]; + setName = result[1]; + maxSetVersion = result[2]; + maxElectionId = result[3]; } else if ( [ServerType.RSSecondary, ServerType.RSArbiter, ServerType.RSOther].indexOf(serverType) >= 0 ) { @@ -400,7 +401,7 @@ function updateRsFromPrimary( }); // Remove hosts not in the response. - const currentAddresses = Array.from(serverDescriptions.keys()) as string[]; + const currentAddresses = Array.from(serverDescriptions.keys()); const responseAddresses = serverDescription.allHosts; currentAddresses .filter((addr: string) => responseAddresses.indexOf(addr) === -1) diff --git a/src/utils.ts b/src/utils.ts index 6910490483..d80f77c9fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import * as os from 'os'; import * as crypto from 'crypto'; import { PromiseProvider } from './promise_provider'; -import { MongoError, AnyError } from './error'; +import { MongoError, AnyError, MongoParseError } from './error'; import { WriteConcern, WriteConcernOptions, W } from './write_concern'; import type { Server } from './sdam/server'; import type { Topology } from './sdam/topology'; @@ -12,12 +12,13 @@ import type { OperationOptions, Hint } from './operations/operation'; import type { ClientSession } from './sessions'; import { ReadConcern } from './read_concern'; import type { Connection } from './cmap/connection'; -import { Document, resolveBSONOptions } from './bson'; +import { Document, ObjectId, resolveBSONOptions } from './bson'; import type { IndexSpecification, IndexDirection } from './operations/indexes'; import type { Explain } from './explain'; import type { MongoClient } from './mongo_client'; import type { CommandOperationOptions, OperationParent } from './operations/command'; import { ReadPreference } from './read_preference'; +import { URL } from 'url'; /** * MongoDB Driver style callback @@ -593,8 +594,8 @@ export class MongoDBNamespace { throw new Error(`Cannot parse namespace from "${namespace}"`); } - const index = namespace.indexOf('.'); - return new MongoDBNamespace(namespace.substring(0, index), namespace.substring(index + 1)); + const [db, ...collection] = namespace.split('.'); + return new MongoDBNamespace(db, collection.join('.')); } } @@ -623,7 +624,7 @@ export function maybePromise( const Promise = PromiseProvider.get(); let result: Promise | void; if (typeof callback !== 'function') { - result = new Promise((resolve, reject) => { + result = new Promise((resolve, reject) => { callback = (err, res) => { if (err) return reject(err); resolve(res); @@ -885,13 +886,13 @@ export interface ClientMetadataOptions { version?: string; platform?: string; }; - appname?: string; + appName?: string; } // eslint-disable-next-line @typescript-eslint/no-var-requires const NODE_DRIVER_VERSION = require('../package.json').version; -export function makeClientMetadata(options: ClientMetadataOptions): ClientMetadata { +export function makeClientMetadata(options?: ClientMetadataOptions): ClientMetadata { options = options ?? {}; const metadata: ClientMetadata = { @@ -905,7 +906,7 @@ export function makeClientMetadata(options: ClientMetadataOptions): ClientMetada architecture: process.arch, version: os.release() }, - platform: `'Node.js ${process.version}, ${os.endianness} (unified)` + platform: `Node.js ${process.version}, ${os.endianness()} (unified)` }; // support optionally provided wrapping driver info @@ -923,33 +924,17 @@ export function makeClientMetadata(options: ClientMetadataOptions): ClientMetada } } - if (options.appname) { - // MongoDB requires the appname not exceed a byte length of 128 - const buffer = Buffer.from(options.appname); + if (options.appName) { + // MongoDB requires the appName not exceed a byte length of 128 + const buffer = Buffer.from(options.appName); metadata.application = { - name: buffer.length > 128 ? buffer.slice(0, 128).toString('utf8') : options.appname + name: buffer.byteLength > 128 ? buffer.slice(0, 128).toString('utf8') : options.appName }; } return metadata; } -/** - * Loops over deprecated keys, will emit warning if key matched in options. - * @internal - * - * @param options - an object of options - * @param list - deprecated option keys - */ -export function emitDeprecatedOptionWarning(options: AnyOptions | undefined, list: string[]): void { - if (!options) return; - list.forEach(option => { - if (typeof options[option] !== 'undefined') { - emitDeprecationWarning(`option [${option}] is deprecated`); - } - }); -} - /** @internal */ export function now(): number { const hrtime = process.hrtime(); @@ -1143,6 +1128,14 @@ export function isSuperset(set: Set | any[], subset: Set | any[]): boo return true; } +export function setDifference(setA: Iterable, setB: Iterable): Set { + const difference = new Set(setA); + for (const elem of setB) { + difference.delete(elem); + } + return difference; +} + export function isRecord( value: unknown, requiredKeys: T @@ -1312,3 +1305,72 @@ export class BufferPool { return result; } } + +/** @public */ +export class HostAddress { + host; + port; + // Driver only works with unix socket path to connect + // SDAM operates only on tcp addresses + socketPath; + isIPv6; + + constructor(hostString: string) { + const escapedHost = hostString.split(' ').join('%20'); // escape spaces, for socket path hosts + const { hostname, port } = new URL(`mongodb://${escapedHost}`); + + if (hostname.endsWith('.sock')) { + // heuristically determine if we're working with a domain socket + this.socketPath = decodeURIComponent(hostname); + } else if (typeof hostname === 'string') { + this.isIPv6 = false; + + let normalized = decodeURIComponent(hostname).toLowerCase(); + if (normalized.startsWith('[') && normalized.endsWith(']')) { + this.isIPv6 = true; + normalized = normalized.substring(1, hostname.length - 1); + } + + this.host = normalized.toLowerCase(); + + if (typeof port === 'number') { + this.port = port; + } else if (typeof port === 'string' && port !== '') { + this.port = Number.parseInt(port, 10); + } else { + this.port = 27017; + } + + if (this.port === 0) { + throw new MongoParseError('Invalid port (zero) with hostname'); + } + } else { + throw new Error('Either socketPath or host must be defined.'); + } + Object.freeze(this); + } + + /** + * @param ipv6Brackets - optionally request ipv6 bracket notation required for connection strings + */ + toString(ipv6Brackets = false): string { + if (typeof this.host === 'string') { + if (this.isIPv6 && ipv6Brackets) { + return `[${this.host}]:${this.port}`; + } + return `${this.host}:${this.port}`; + } + return `${this.socketPath}`; + } + + static fromString(s: string): HostAddress { + return new HostAddress(s); + } +} + +export const DEFAULT_PK_FACTORY = { + // We prefer not to rely on ObjectId having a createPk method + createPk(): ObjectId { + return new ObjectId(); + } +}; diff --git a/src/write_concern.ts b/src/write_concern.ts index 622fdd1c23..b2ab9262fc 100644 --- a/src/write_concern.ts +++ b/src/write_concern.ts @@ -12,13 +12,15 @@ export interface WriteConcernSettings { /** The write concern */ w?: W; /** The write concern timeout */ - wtimeout?: number; - /** The write concern timeout */ wtimeoutMS?: number; /** The journal write concern */ - j?: boolean; - /** The journal write concern */ journal?: boolean; + + // legacy options + /** The journal write concern */ + j?: boolean; + /** The write concern timeout */ + wtimeout?: number; /** The file sync write concern */ fsync?: boolean | 1; } @@ -70,13 +72,19 @@ export class WriteConcern { /** Construct a WriteConcern given an options object. */ static fromOptions( - options?: WriteConcernOptions | WriteConcern, + options?: WriteConcernOptions | WriteConcern | W, inherit?: WriteConcernOptions | WriteConcern ): WriteConcern | undefined { if (typeof options === 'undefined') return undefined; inherit = inherit ?? {}; - const opts: WriteConcern | WriteConcernSettings | undefined = - options instanceof WriteConcern ? options : options.writeConcern; + let opts; + if (typeof options === 'string' || typeof options === 'number') { + opts = { w: options }; + } else if (options instanceof WriteConcern) { + opts = options; + } else { + opts = options.writeConcern; + } const parentOpts: WriteConcern | WriteConcernSettings | undefined = inherit instanceof WriteConcern ? inherit : inherit.writeConcern; diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 08752d0eb5..2ce7fc2eb9 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -30,7 +30,7 @@ "jsdoc/valid-types": "off", // Since we use ts-node we should always require the TS code - "no-restricted-modules": ["error", { "patterns": ["**/../lib/**"] }], + "no-restricted-modules": ["error", { "patterns": ["**/../lib/**", "mongodb-mock-server"] }], "no-console": "off", "eqeqeq": ["error", "always", { "null": "ignore" }], diff --git a/test/functional/apm.test.js b/test/functional/apm.test.js index 59eb929284..347a570b23 100644 --- a/test/functional/apm.test.js +++ b/test/functional/apm.test.js @@ -30,7 +30,7 @@ describe('APM', function () { instrumentation.on('succeeded', filterForCommands('insert', succeeded)); let client = this.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -52,7 +52,7 @@ describe('APM', function () { started = []; succeeded = []; client = this.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -124,7 +124,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = this.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -153,7 +153,7 @@ describe('APM', function () { const succeeded = []; const self = this; const client = this.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -183,33 +183,36 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); client.on('commandStarted', filterForCommands('listCollections', started)); client.on('commandSucceeded', filterForCommands('listCollections', succeeded)); - return client.connect().then(client => { - const db = client.db(self.configuration.db); + return client + .connect() + .then(client => { + const db = client.db(self.configuration.db); - return db - .collection('apm_test_list_collections') - .insertOne({ a: 1 }, self.configuration.writeConcernMax()) - .then(r => { - expect(r).property('insertedId').to.exist; - return db.listCollections({}, { readPreference: ReadPreference.PRIMARY }).toArray(); - }) - .then(() => - db.listCollections({}, { readPreference: ReadPreference.SECONDARY }).toArray() - ) - .then(() => { - expect(started).to.have.lengthOf(2); - expect(started[0]).property('address').to.not.equal(started[1].address); + return db + .collection('apm_test_list_collections') + .insertOne({ a: 1 }, self.configuration.writeConcernMax()) + .then(r => { + expect(r).property('insertedId').to.exist; + return db.listCollections({}, { readPreference: ReadPreference.primary }).toArray(); + }) + .then(() => + db.listCollections({}, { readPreference: ReadPreference.secondary }).toArray() + ) + .then(() => { + expect(started).to.have.lengthOf(2); + expect(started[0]).property('address').to.not.equal(started[1].address); - return client.close(); - }); - }); + return client.close(); + }); + }) + .catch(err => expect(err).to.not.exist); } }); @@ -221,7 +224,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -301,7 +304,7 @@ describe('APM', function () { // }); const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -337,7 +340,7 @@ describe('APM', function () { const succeeded = []; const failed = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -411,7 +414,7 @@ describe('APM', function () { const succeeded = []; const failed = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -469,7 +472,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -511,7 +514,7 @@ describe('APM', function () { const succeeded = []; const failed = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -561,7 +564,7 @@ describe('APM', function () { const succeeded = []; const failed = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -593,7 +596,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -628,7 +631,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -663,7 +666,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -693,7 +696,7 @@ describe('APM', function () { test: function () { const self = this; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -743,7 +746,7 @@ describe('APM', function () { for (let i = 0; i < 2500; i++) docs.push({ a: i }); const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); @@ -789,7 +792,7 @@ describe('APM', function () { const started = []; const succeeded = []; const client = self.configuration.newClient( - { w: 1 }, + { writeConcern: { w: 1 } }, { maxPoolSize: 1, monitorCommands: true } ); diff --git a/test/functional/buffering_proxy.test.js b/test/functional/buffering_proxy.test.js index 4c1b553811..83af2ec58b 100644 --- a/test/functional/buffering_proxy.test.js +++ b/test/functional/buffering_proxy.test.js @@ -1,7 +1,7 @@ 'use strict'; var test = require('./shared').assert; var co = require('co'); -var mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const { ReadPreference, ObjectId } = require('../../src'); const { expect } = require('chai'); diff --git a/test/functional/bulk.test.js b/test/functional/bulk.test.js index 836d44d980..84f4a4ab86 100644 --- a/test/functional/bulk.test.js +++ b/test/functional/bulk.test.js @@ -1201,7 +1201,7 @@ describe('Bulk', function () { batch.insert({ a: 1 }); batch.insert({ a: 2 }); - batch.execute({ writeConcern: { w: 2, wtimeout: 1000 } }, function (err) { + batch.execute({ writeConcern: { w: 2, wtimeoutMS: 1000 } }, function (err) { test.ok(err != null); test.ok(err.code != null); test.ok(err.errmsg != null); @@ -1303,7 +1303,7 @@ describe('Bulk', function () { batch.insert({ a: 1 }); batch.insert({ a: 2 }); - batch.execute({ writeConcern: { w: 2, wtimeout: 1000 } }, function (err) { + batch.execute({ writeConcern: { w: 2, wtimeoutMS: 1000 } }, function (err) { test.ok(err != null); test.ok(err.code != null); test.ok(err.errmsg != null); diff --git a/test/functional/change_stream.test.js b/test/functional/change_stream.test.js index b2cf12f1a1..470a077742 100644 --- a/test/functional/change_stream.test.js +++ b/test/functional/change_stream.test.js @@ -4,7 +4,7 @@ const { Transform, PassThrough } = require('stream'); const { MongoNetworkError } = require('../../src/error'); const { delay, setupDatabase, withClient, withCursor } = require('./shared'); const co = require('co'); -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const chai = require('chai'); const expect = chai.expect; const sinon = require('sinon'); @@ -1458,7 +1458,8 @@ describe('Change Streams', function () { } }); - it('should resume piping of Change Streams when a resumable error is encountered', { + it.skip('should resume piping of Change Streams when a resumable error is encountered', { + // TODO(2704) metadata: { requires: { os: '!win32', // (fs.watch isn't reliable on win32) @@ -1579,10 +1580,12 @@ describe('Change Streams', function () { const collection = database.collection('MongoNetworkErrorTestPromises'); const changeStream = collection.watch(pipeline); - const outStream = fs.createWriteStream(filename); + const outStream = fs.createWriteStream(filename, { flags: 'w' }); this.defer(() => outStream.close()); - changeStream.stream({ transform: JSON.stringify }).pipe(outStream); + changeStream + .stream({ transform: change => JSON.stringify(change) + '\n' }) + .pipe(outStream); this.defer(() => changeStream.close()); // Listen for changes to the file const watcher = fs.watch(filename, eventType => { @@ -1590,7 +1593,7 @@ describe('Change Streams', function () { expect(eventType).to.equal('change'); const fileContents = fs.readFileSync(filename, 'utf8'); - const parsedFileContents = JSON.parse(fileContents); + const parsedFileContents = JSON.parse(fileContents.split(/\n/)[0]); expect(parsedFileContents).to.have.nested.property('fullDocument.a', 1); done(); }); diff --git a/test/functional/cmap/connection.test.js b/test/functional/cmap/connection.test.js index 0477dd6374..f59a128d5e 100644 --- a/test/functional/cmap/connection.test.js +++ b/test/functional/cmap/connection.test.js @@ -4,7 +4,7 @@ const { Connection } = require('../../../src/cmap/connection'); const { connect } = require('../../../src/cmap/connect'); const { expect } = require('chai'); const { setupDatabase } = require('../../functional/shared'); -const { ns } = require('../../../src/utils'); +const { ns, HostAddress } = require('../../../src/utils'); describe('Connection - functional/cmap', function () { before(function () { @@ -55,18 +55,19 @@ describe('Connection - functional/cmap', function () { }); }); - it('should support socket timeouts', { + it.skip('should support socket timeouts', { + // FIXME: NODE-2941 metadata: { requires: { os: '!win32' // NODE-2941: 240.0.0.1 doesnt work for windows } }, test: function (done) { - const connectOptions = Object.assign({ - host: '240.0.0.1', + const connectOptions = { + hostAddress: new HostAddress('240.0.0.1'), connectionType: Connection, connectionTimeout: 500 - }); + }; connect(connectOptions, err => { expect(err).to.exist; diff --git a/test/functional/collations.test.js b/test/functional/collations.test.js index 7df51d7255..85468f7049 100644 --- a/test/functional/collations.test.js +++ b/test/functional/collations.test.js @@ -1,6 +1,6 @@ 'use strict'; const setupDatabase = require('./shared').setupDatabase; -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const expect = require('chai').expect; const { Long, Code } = require('../../src'); diff --git a/test/functional/collection.test.js b/test/functional/collection.test.js index b824690f30..3f2fc4548e 100644 --- a/test/functional/collection.test.js +++ b/test/functional/collection.test.js @@ -3,7 +3,7 @@ const setupDatabase = require('./shared').setupDatabase; const chai = require('chai'); const expect = chai.expect; const sinonChai = require('sinon-chai'); -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); chai.use(sinonChai); describe('Collection', function () { diff --git a/test/functional/command_write_concern.test.js b/test/functional/command_write_concern.test.js index bf3dc42828..440b90e703 100644 --- a/test/functional/command_write_concern.test.js +++ b/test/functional/command_write_concern.test.js @@ -1,10 +1,10 @@ 'use strict'; const co = require('co'); -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const expect = require('chai').expect; const { ObjectId, Code } = require('../../src'); -const TEST_OPTIONS = { writeConcern: { w: 2, wtimeout: 1000 } }; +const TEST_OPTIONS = { writeConcern: { w: 2, wtimeoutMS: 1000 } }; class WriteConcernTest { constructor(configuration) { @@ -110,7 +110,10 @@ function writeConcernTest(command, testFn) { t.run(command, (client, db) => testFn.call(this, db, Object.assign({}, TEST_OPTIONS), err => { expect(err).to.not.exist; - expect(TEST_OPTIONS.writeConcern).to.deep.equal(t.commandResult.writeConcern); + expect({ + w: TEST_OPTIONS.writeConcern.w, + wtimeout: TEST_OPTIONS.writeConcern.wtimeoutMS + }).to.deep.equal(t.commandResult.writeConcern); client.close(done); }) ); diff --git a/test/functional/connection.test.js b/test/functional/connection.test.js index 5c4be9c34b..5e9c62a45e 100644 --- a/test/functional/connection.test.js +++ b/test/functional/connection.test.js @@ -14,8 +14,11 @@ describe('Connection - functional', function () { test: function (done) { var configuration = this.configuration; var client = configuration.newClient( - { w: 1 }, - { maxPoolSize: 1, host: '/tmp/mongodb-27017.sock', heartbeatFrequencyMS: 250 } + `mongodb://${encodeURIComponent('/tmp/mongodb-27017.sock')}?w=1`, + { + maxPoolSize: 1, + heartbeatFrequencyMS: 250 + } ); client.connect(function (err, client) { @@ -34,8 +37,8 @@ describe('Connection - functional', function () { test: function (done) { var configuration = this.configuration; var client = configuration.newClient( - { w: 1 }, - { maxPoolSize: 1, host: '/tmp/mongodb-27017.sock' } + `mongodb://${encodeURIComponent('/tmp/mongodb-27017.sock')}?w=1`, + { maxPoolSize: 1 } ); client.connect(function (err, client) { @@ -96,40 +99,6 @@ describe('Connection - functional', function () { } }); - it('should connect to server using domain socket with undefined port', { - metadata: { requires: { topology: 'single', os: '!win32' } }, - - test: function (done) { - var configuration = this.configuration; - var client = configuration.newClient( - { w: 1 }, - { maxPoolSize: 1, host: '/tmp/mongodb-27017.sock', port: undefined } - ); - - client.connect(function (err, client) { - expect(err).to.not.exist; - var db = client.db(configuration.db); - - db.collection('domainSocketCollection1').insert( - { x: 1 }, - { writeConcern: { w: 1 } }, - function (err) { - expect(err).to.not.exist; - - db.collection('domainSocketCollection1') - .find({ x: 1 }) - .toArray(function (err, items) { - expect(err).to.not.exist; - test.equal(1, items.length); - - client.close(done); - }); - } - ); - }); - } - }); - /** * @param {any} configuration * @param {any} testName @@ -137,8 +106,8 @@ describe('Connection - functional', function () { */ function connectionTester(configuration, testName, callback) { return function (err, client) { - var db = client.db(configuration.db); expect(err).to.not.exist; + var db = client.db(configuration.db); db.collection(testName, function (err, collection) { expect(err).to.not.exist; @@ -217,19 +186,16 @@ describe('Connection - functional', function () { expect(err).to.not.exist; var db = client.db(configuration.db); - db.addUser(username, password, function (err) { + db.addUser(username, password, { roles: ['read'] }, function (err) { expect(err).to.not.exist; client.close(restOfTest); }); }); function restOfTest() { - var opts = { auth: { username, password } }; + var opts = { auth: { username, password }, authSource: configuration.db }; - const testClient = configuration.newClient( - configuration.url('baduser', 'badpassword'), - opts - ); + const testClient = configuration.newClient(opts); testClient.connect( connectionTester(configuration, 'testConnectGoodAuthAsOption', function (client) { diff --git a/test/functional/cursor.test.js b/test/functional/cursor.test.js index 7524fe45d8..4afae2eb16 100644 --- a/test/functional/cursor.test.js +++ b/test/functional/cursor.test.js @@ -1899,7 +1899,7 @@ describe('Cursor', function () { let closeCount = 0; const docs = Array.from({ length: 100 }).map(() => ({ a: 1 })); - collection.insertMany(docs, { w: 'majority', wtimeout: 5000 }, err => { + collection.insertMany(docs, { w: 'majority', wtimeoutMS: 5000 }, err => { expect(err).to.not.exist; const cursor = collection.find({}, { tailable: true, awaitData: true }); diff --git a/test/functional/custom_pk.test.js b/test/functional/custom_pk.test.js index 0526810bdd..aec25f9863 100644 --- a/test/functional/custom_pk.test.js +++ b/test/functional/custom_pk.test.js @@ -25,7 +25,7 @@ describe('Custom PK', function () { var client = configuration.newClient( { - w: 1, + writeConcern: { w: 1 }, maxPoolSize: 1 }, { diff --git a/test/functional/find_and_modify.test.js b/test/functional/find_and_modify.test.js index 9b7266333f..57605f64bf 100644 --- a/test/functional/find_and_modify.test.js +++ b/test/functional/find_and_modify.test.js @@ -216,8 +216,8 @@ describe('Find and Modify', function () { const configuration = this.configuration; const client = configuration.newClient({ readPreference: 'secondary' }, { maxPoolSize: 1 }); client.connect((err, client) => { - const db = client.db(configuration.db); expect(err).to.not.exist; + const db = client.db(configuration.db); const collection = db.collection('findAndModifyTEST'); // Execute findOneAndUpdate diff --git a/test/functional/max_staleness.test.js b/test/functional/max_staleness.test.js index 1066f8a9f8..d87f4336ad 100644 --- a/test/functional/max_staleness.test.js +++ b/test/functional/max_staleness.test.js @@ -1,7 +1,7 @@ 'use strict'; const { Long } = require('bson'); const { expect } = require('chai'); -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const { ReadPreference } = require('../../src'); const test = {}; diff --git a/test/functional/mongo_client.test.js b/test/functional/mongo_client.test.js index d9039c7ecd..250df655cd 100644 --- a/test/functional/mongo_client.test.js +++ b/test/functional/mongo_client.test.js @@ -25,7 +25,7 @@ describe('MongoClient', function () { const client = configuration.newClient( {}, { - writeConcern: { w: 1, wtimeout: 1000, fsync: true, j: true }, + writeConcern: { w: 1, wtimeoutMS: 1000, fsync: true, j: true }, readPreference: 'nearest', readPreferenceTags: { loc: 'ny' }, forceServerObjectId: true, @@ -35,8 +35,7 @@ describe('MongoClient', function () { } }, serializeFunctions: true, - raw: true, - numberOfRetries: 10 + raw: true } ); @@ -49,13 +48,12 @@ describe('MongoClient', function () { test.equal(true, db.writeConcern.j); test.equal('nearest', db.s.readPreference.mode); - test.deepEqual({ loc: 'ny' }, db.s.readPreference.tags); + test.deepEqual([{ loc: 'ny' }], db.s.readPreference.tags); test.equal(true, db.s.options.forceServerObjectId); test.equal(1, db.s.pkFactory.createPk()); test.equal(true, db.bsonOptions.serializeFunctions); test.equal(true, db.bsonOptions.raw); - test.equal(10, db.s.options.numberOfRetries); client.close(done); }); diff --git a/test/functional/mongo_client_options.test.js b/test/functional/mongo_client_options.test.js index 84d12bf9b7..2088236ef4 100644 --- a/test/functional/mongo_client_options.test.js +++ b/test/functional/mongo_client_options.test.js @@ -25,7 +25,7 @@ describe('MongoClient Options', function () { function (err, client) { expect(err) .property('message') - .to.match(/option notlegal is not supported/); + .to.match(/options notlegal, validateoptions are not supported/); expect(client).to.not.exist; done(); } @@ -33,6 +33,24 @@ describe('MongoClient Options', function () { } }); + it('should error on unexpected options (promise)', { + metadata: { requires: { topology: 'single' } }, + + test() { + MongoClient.connect(this.configuration.url(), { + maxPoolSize: 4, + notlegal: {}, + validateOptions: true + }) + .then(() => expect().fail()) + .catch(err => { + expect(err) + .property('message') + .to.match(/options notlegal, validateoptions are not supported/); + }); + } + }); + it('must respect an infinite connectTimeoutMS for the streaming protocol', { metadata: { requires: { topology: 'replicaset', mongodb: '>= 4.4' } }, test: function (done) { diff --git a/test/functional/operation_example.test.js b/test/functional/operation_example.test.js index 0a6491cdb1..2d645cd61d 100644 --- a/test/functional/operation_example.test.js +++ b/test/functional/operation_example.test.js @@ -4153,14 +4153,9 @@ describe('Operation Examples', function () { var configuration = this.configuration; // Replica configuration - var client = new Topology( - [ - { host: configuration.host, port: configuration.port }, - { host: configuration.host, port: configuration.port + 1 }, - { host: configuration.host, port: configuration.port + 2 } - ], - { replicaSet: configuration.replicasetName } - ); + var client = new Topology(configuration.options.hostAddresses, { + replicaSet: configuration.replicasetName + }); client.connect(function (err, client) { expect(err).to.not.exist; diff --git a/test/functional/replicaset_mock.test.js b/test/functional/replicaset_mock.test.js index 833d7c56af..9d583f6fb8 100644 --- a/test/functional/replicaset_mock.test.js +++ b/test/functional/replicaset_mock.test.js @@ -1,6 +1,6 @@ 'use strict'; const { expect } = require('chai'); -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const { ObjectId } = require('bson'); const { Logger } = require('../../src/logger'); diff --git a/test/functional/saslprep.test.js b/test/functional/saslprep.test.js index d22d6b58f8..e0575057b8 100644 --- a/test/functional/saslprep.test.js +++ b/test/functional/saslprep.test.js @@ -66,10 +66,7 @@ describe('SASLPrep', function () { { username: '\u2168', password: 'IV' }, { username: '\u2168', password: 'I\u00ADV' }, { username: '\u2168', password: '\u2163' } - ].forEach(user => { - const username = user.username; - const password = user.password; - + ].forEach(({ username, password }) => { it(`should be able to login with username "${username}" and password "${password}"`, { metadata: { requires: { @@ -79,8 +76,7 @@ describe('SASLPrep', function () { }, test: function () { const options = { - username: username, - password: password, + auth: { username, password }, authSource: 'admin', authMechanism: 'SCRAM-SHA-256' }; diff --git a/test/functional/spec-runner/context.js b/test/functional/spec-runner/context.js index 476e2f89a3..a696d5f4aa 100644 --- a/test/functional/spec-runner/context.js +++ b/test/functional/spec-runner/context.js @@ -34,6 +34,7 @@ class TestRunnerContext { const defaults = { password: undefined, user: undefined, + authSource: undefined, useSessions: true, skipPrepareDatabase: false }; @@ -42,6 +43,7 @@ class TestRunnerContext { this.useSessions = opts.useSessions; this.user = opts.user; this.password = opts.password; + this.authSource = opts.authSource; this.sharedClient = null; this.failPointClients = []; this.appliedFailPoints = []; @@ -70,7 +72,7 @@ class TestRunnerContext { resolveConnectionString(config, { useMultipleMongoses: true }, this) ); if (config.topologyType === 'Sharded') { - this.failPointClients = config.options.hosts.map(proxy => + this.failPointClients = config.options.hostAddresses.map(proxy => config.newClient(`mongodb://${proxy.host}:${proxy.port}/`) ); } diff --git a/test/functional/spec-runner/utils.js b/test/functional/spec-runner/utils.js index a2f56ffb43..dfc0d30ed3 100644 --- a/test/functional/spec-runner/utils.js +++ b/test/functional/spec-runner/utils.js @@ -5,10 +5,13 @@ function resolveConnectionString(configuration, spec, context) { const useMultipleMongoses = spec && !!spec.useMultipleMongoses; const user = context && context.user; const password = context && context.password; + const authSource = context && context.authSource; const connectionString = isShardedEnvironment && !useMultipleMongoses - ? `mongodb://${configuration.host}:${configuration.port}/${configuration.db}?directConnection=false` - : configuration.url(user, password); + ? `mongodb://${configuration.host}:${configuration.port}/${ + configuration.db + }?directConnection=false${authSource ? '&authSource=${authSource}' : ''}` + : configuration.url(user, password, { authSource }); return connectionString; } diff --git a/test/functional/uri.test.js b/test/functional/uri.test.js index d5c7cf1cb1..90787b9b4d 100644 --- a/test/functional/uri.test.js +++ b/test/functional/uri.test.js @@ -116,7 +116,7 @@ describe('URI', function () { client.connect((err, client) => { expect(err).to.not.exist; expect(client).to.exist; - expect(client.s.options.replicaSet).to.exist.and.equal(config.replicasetName); + expect(client.options.replicaSet).to.exist.and.equal(config.replicasetName); client.close(done); }); } diff --git a/test/functional/view.test.js b/test/functional/view.test.js index 0e24f8330e..d853a7812c 100644 --- a/test/functional/view.test.js +++ b/test/functional/view.test.js @@ -1,6 +1,6 @@ 'use strict'; var expect = require('chai').expect, - mock = require('mongodb-mock-server'), + mock = require('../tools/mock'), co = require('co'); const { Long } = require('../../src'); diff --git a/test/manual/data_lake.test.js b/test/manual/data_lake.test.js index 8e22d93f4c..c41be3c47c 100644 --- a/test/manual/data_lake.test.js +++ b/test/manual/data_lake.test.js @@ -12,7 +12,8 @@ describe('Atlas Data Lake', function () { skipPrepareDatabase: true, useSessions: false, user: 'mhuser', - password: 'pencil' + password: 'pencil', + authSource: 'admin' }); let testSuites = gatherTestSuites(path.resolve(__dirname, '../spec/atlas-data-lake-testing')); diff --git a/test/tools/mock.js b/test/tools/mock.js new file mode 100644 index 0000000000..0db171308a --- /dev/null +++ b/test/tools/mock.js @@ -0,0 +1,75 @@ +'use strict'; + +const { + createServer: superCreateServer, + cleanup, + DEFAULT_ISMASTER, + DEFAULT_ISMASTER_36 + // eslint-disable-next-line no-restricted-modules +} = require('mongodb-mock-server'); +const { HostAddress } = require('../../src/utils'); + +/** + * @callback GetHostAddress + * @returns {import('../../src/mongo_client').HostAddress} + */ + +/** + * @callback GetAddress + * @returns {({host: string, port: number})} + */ + +/** + * @callback GetURI + * @returns {string} + */ + +/** + * @typedef {Object} MockServer + * @property {Function} onRead - todo + * @property {string} host - todo + * @property {number} port - todo + * @property {import('net').Server | import('tls').Server} server - todo + * @property {boolean} tlsEnabled - todo + * @property {any} messages - todo + * @property {any} state - todo + * @property {number} connections - todo + * @property {any[]} sockets - todo + * @property {object} messageHandlers - todo + * // methods + * @property {GetHostAddress} hostAddress - the HostAddress type + * @property {GetAddress} address - the address as a string + * @property {GetURI} uri - the connection string + * @property {Function} destroy - todo + * @property {Function} start - todo + * @property {Function} receive - todo + * @property {Function} setMessageHandler - todo + * @property {Function} addMessageHandler - todo + */ + +/** + * Make a mock mongodb server. + * + * @param {number} port - port number + * @param {string} host - address + * @param {object} options - options + * @returns {Promise} + */ +function createServer(port, host, options) { + const willBeServer = superCreateServer(port, host, options); + willBeServer.then(s => { + s.hostAddress = () => { + const address = s.address(); + return new HostAddress(`${address.host}:${address.port}`); + }; + return s; + }); + return willBeServer; +} + +module.exports = { + createServer, + cleanup, + DEFAULT_ISMASTER, + DEFAULT_ISMASTER_36 +}; diff --git a/test/tools/runner/config.js b/test/tools/runner/config.js index c0578e7770..fd450365d5 100644 --- a/test/tools/runner/config.js +++ b/test/tools/runner/config.js @@ -6,7 +6,12 @@ const util = require('util'); const { MongoClient } = require('../../../src/mongo_client'); const { Topology } = require('../../../src/sdam/topology'); const { TopologyType } = require('../../../src/sdam/common'); +const { parseURI } = require('../../../src/connection_string'); +const { HostAddress } = require('../../../src/utils'); +/** + * @param {Record} obj + */ function convertToConnStringMap(obj) { let result = []; Object.keys(obj).forEach(key => { @@ -16,25 +21,32 @@ function convertToConnStringMap(obj) { return result.join(','); } -class NativeConfiguration { - constructor(parsedURI, context) { +class TestConfiguration { + constructor(uri, context) { + const { url, hosts } = parseURI(uri); + const hostAddresses = hosts.map(HostAddress.fromString); this.topologyType = context.topologyType; this.version = context.version; this.clientSideEncryption = context.clientSideEncryption; - this.options = Object.assign( - { - auth: parsedURI.auth, - hosts: parsedURI.hosts, - host: parsedURI.hosts[0] ? parsedURI.hosts[0].host : 'localhost', - port: parsedURI.hosts[0] ? parsedURI.hosts[0].port : 27017, - db: parsedURI.auth && parsedURI.auth.db ? parsedURI.auth.db : 'integration_tests' - }, - parsedURI.options - ); - - this.writeConcern = function () { - return { writeConcern: { w: 1 } }; + this.options = { + hosts, + hostAddresses, + hostAddress: hostAddresses[0], + host: hostAddresses[0].host, + port: typeof hostAddresses[0].host === 'string' ? hostAddresses[0].port : undefined, + db: url.pathname.slice(1) ? url.pathname.slice(1) : 'integration_tests', + replicaSet: url.searchParams.get('replicaSet') }; + if (url.username) { + this.options.auth = { + username: url.username, + password: url.password + }; + } + } + + writeConcern() { + return { writeConcern: { w: 1 } }; } get host() { @@ -121,18 +133,24 @@ class NativeConfiguration { }; if (this.options.auth) { - let auth = this.options.auth.username; - if (this.options.auth.password) { - auth = `${auth}:${this.options.auth.password}`; + const { username, password } = this.options.auth; + if (username) { + urlOptions.auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}`; } + } - urlOptions.auth = auth; + if (dbOptions.auth) { + const { username, password } = dbOptions.auth; + if (username) { + urlOptions.auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}`; + } + delete urlOptions.query.auth; } - // TODO(NODE-2704): Uncomment this, unix socket related issues - // Reflect.deleteProperty(serverOptions, 'host'); - // Reflect.deleteProperty(serverOptions, 'port'); const connectionString = url.format(urlOptions); + if (Reflect.has(serverOptions, 'host') || Reflect.has(serverOptions, 'port')) { + throw new Error(`Cannot use options to specify host/port, must be in ${connectionString}`); + } return new MongoClient(connectionString, serverOptions); } @@ -144,7 +162,8 @@ class NativeConfiguration { } options = Object.assign({}, options); - const hosts = host == null ? [].concat(this.options.hosts) : [{ host, port }]; + const hosts = + host == null ? [].concat(this.options.hostAddresses) : [new HostAddress(`${host}:${port}`)]; return new Topology(hosts, options); } @@ -161,18 +180,19 @@ class NativeConfiguration { // NOTE: The only way to force a sharded topology with the driver is to duplicate // the host entry. This will eventually be solved by autodetection. if (this.topologyType === TopologyType.Sharded) { - const firstHost = this.options.hosts[0]; - multipleHosts = `${firstHost.host}:${firstHost.port},${firstHost.host}:${firstHost.port}`; + const firstHost = this.options.hostAddresses[0]; + multipleHosts = `${firstHost.host}:${firstHost.port}`; } else { - multipleHosts = this.options.hosts + multipleHosts = this.options.hostAddresses .reduce((built, host) => { - built.push(`${host.host}:${host.port}`); + built.push(host.type === 'tcp' ? `${host.host}:${host.port}` : host.host); return built; }, []) .join(','); } } + /** @type {Record} */ const urlObject = { protocol: 'mongodb', slashes: true, @@ -205,6 +225,10 @@ class NativeConfiguration { ) }); } + + if (options.authSource) { + query.authSource = options.authSource; + } } if (multipleHosts) { @@ -216,7 +240,7 @@ class NativeConfiguration { writeConcernMax() { if (this.topologyType !== TopologyType.Single) { - return { writeConcern: { w: 'majority', wtimeout: 30000 } }; + return { writeConcern: { w: 'majority', wtimeoutMS: 30000 } }; } return { writeConcern: { w: 1 } }; @@ -244,4 +268,4 @@ class NativeConfiguration { } } -module.exports = NativeConfiguration; +module.exports = { TestConfiguration }; diff --git a/test/tools/runner/index.js b/test/tools/runner/index.js index 75e8a95bdc..d1d13b3265 100644 --- a/test/tools/runner/index.js +++ b/test/tools/runner/index.js @@ -3,10 +3,9 @@ const path = require('path'); const fs = require('fs'); const { MongoClient } = require('../../../src'); -const TestConfiguration = require('./config'); -const { parseConnectionString } = require('../../../src/connection_string'); +const { TestConfiguration } = require('./config'); const { eachAsync } = require('../../../src/utils'); -const mock = require('mongodb-mock-server'); +const mock = require('../mock'); const wtfnode = require('wtfnode'); const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017'; @@ -78,16 +77,8 @@ before(function (_done) { // replace this when mocha supports dynamic skipping with `afterEach` filterOutTests(this._runnable.parent); - - parseConnectionString(MONGODB_URI, (err, parsedURI) => { - if (err) { - done(err); - return; - } - - this.configuration = new TestConfiguration(parsedURI, context); - done(); - }); + this.configuration = new TestConfiguration(MONGODB_URI, context); + done(); }); }); }); diff --git a/test/tools/runner/plugins/client_leak_checker.js b/test/tools/runner/plugins/client_leak_checker.js index f2eda804ea..6e485ae158 100644 --- a/test/tools/runner/plugins/client_leak_checker.js +++ b/test/tools/runner/plugins/client_leak_checker.js @@ -1,6 +1,6 @@ 'use strict'; -const TestConfiguration = require('../config'); +const { TestConfiguration } = require('../config'); const chalk = require('chalk'); let activeClients = []; diff --git a/test/unit/bulk_write.test.js b/test/unit/bulk_write.test.js index 9242222c15..c9fd13403b 100644 --- a/test/unit/bulk_write.test.js +++ b/test/unit/bulk_write.test.js @@ -1,7 +1,7 @@ 'use strict'; const expect = require('chai').expect; -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); describe('Bulk Writes', function () { const test = {}; diff --git a/test/unit/bypass_validation.test.js b/test/unit/bypass_validation.test.js index f0db10fe45..e7be3c7578 100644 --- a/test/unit/bypass_validation.test.js +++ b/test/unit/bypass_validation.test.js @@ -1,7 +1,7 @@ 'use strict'; const expect = require('chai').expect; -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); describe('bypass document validation', function () { const test = {}; diff --git a/test/unit/client.test.js b/test/unit/client.test.js index fa28c63af9..d9519193d9 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1,7 +1,7 @@ 'use strict'; const expect = require('chai').expect; -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); describe('Client (unit)', function () { let server; diff --git a/test/unit/cmap/connection.test.js b/test/unit/cmap/connection.test.js index 7a09ad2da6..28606c376f 100644 --- a/test/unit/cmap/connection.test.js +++ b/test/unit/cmap/connection.test.js @@ -1,6 +1,6 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { connect } = require('../../../src/cmap/connect'); const { Connection } = require('../../../src/cmap/connection'); const { expect } = require('chai'); @@ -21,7 +21,7 @@ describe('Connection - unit/cmap', function () { // blackhole all other requests }); - connect(Object.assign({ connectionType: Connection }, server.address()), (err, conn) => { + connect({ connectionType: Connection, hostAddress: server.hostAddress() }, (err, conn) => { expect(err).to.not.exist; expect(conn).to.exist; @@ -44,7 +44,7 @@ describe('Connection - unit/cmap', function () { // blackhole all other requests }); - connect(Object.assign({ connectionType: Connection }, server.address()), (err, conn) => { + connect({ connectionType: Connection, hostAddress: server.hostAddress() }, (err, conn) => { expect(err).to.not.exist; expect(conn).to.exist; diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index f786aece84..21abde3580 100644 --- a/test/unit/cmap/connection_pool.test.js +++ b/test/unit/cmap/connection_pool.test.js @@ -5,7 +5,7 @@ const { loadSpecTests } = require('../../spec'); const { ConnectionPool } = require('../../../src/cmap/connection_pool'); const { WaitQueueTimeoutError } = require('../../../src/cmap/errors'); const { EventEmitter } = require('events'); -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const cmapEvents = require('../../../src/cmap/events'); const sinon = require('sinon'); const { expect } = require('chai'); @@ -52,7 +52,7 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool(Object.assign({ maxPoolSize: 1 }, server.address())); + const pool = new ConnectionPool({ maxPoolSize: 1, hostAddress: server.hostAddress() }); const events = []; pool.on('connectionClosed', event => events.push(event)); @@ -93,9 +93,11 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool( - Object.assign({ maxPoolSize: 1, socketTimeout: 200 }, server.address()) - ); + const pool = new ConnectionPool({ + maxPoolSize: 1, + socketTimeout: 200, + hostAddress: server.hostAddress() + }); pool.withConnection( (err, conn, cb) => { @@ -119,9 +121,11 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool( - Object.assign({ maxPoolSize: 1, waitQueueTimeoutMS: 200 }, server.address()) - ); + const pool = new ConnectionPool({ + maxPoolSize: 1, + waitQueueTimeoutMS: 200, + hostAddress: server.hostAddress() + }); pool.checkOut((err, conn) => { expect(err).to.not.exist; @@ -153,7 +157,7 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool(Object.assign({}, server.address())); + const pool = new ConnectionPool({ hostAddress: server.hostAddress() }); const callback = (err, result) => { expect(err).to.not.exist; expect(result).to.exist; @@ -178,7 +182,10 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool(Object.assign({ waitQueueTimeoutMS: 200 }, server.address())); + const pool = new ConnectionPool({ + waitQueueTimeoutMS: 200, + hostAddress: server.hostAddress() + }); const callback = err => { expect(err).to.exist; @@ -201,7 +208,7 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool(Object.assign({}, server.address())); + const pool = new ConnectionPool({ hostAddress: server.hostAddress() }); const callback = (err, result) => { expect(err).to.exist; expect(result).to.not.exist; @@ -223,7 +230,7 @@ describe('Connection Pool', function () { } }); - const pool = new ConnectionPool(Object.assign({ maxPoolSize: 1 }, server.address())); + const pool = new ConnectionPool({ maxPoolSize: 1, hostAddress: server.hostAddress() }); const events = []; pool.on('connectionCheckedOut', event => events.push(event)); @@ -252,7 +259,7 @@ describe('Connection Pool', function () { let pool = undefined; function createPool(options) { - options = Object.assign({}, options, {}, server.address()); + options = Object.assign({}, options, { hostAddress: server.hostAddress() }); pool = new ConnectionPool(options); ALL_POOL_EVENTS.forEach(ev => { pool.on(ev, x => { diff --git a/test/unit/core/common.js b/test/unit/core/common.js index d3e808d9d3..49019c7e52 100644 --- a/test/unit/core/common.js +++ b/test/unit/core/common.js @@ -1,6 +1,6 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { ObjectId, Timestamp, Binary } = require('bson'); class ReplSetFixture { diff --git a/test/unit/core/connect.test.js b/test/unit/core/connect.test.js index 124cee6855..a09dd9cf06 100644 --- a/test/unit/core/connect.test.js +++ b/test/unit/core/connect.test.js @@ -1,6 +1,6 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { expect } = require('chai'); const EventEmitter = require('events'); @@ -8,6 +8,7 @@ const { connect } = require('../../../src/cmap/connect'); const { MongoCredentials } = require('../../../src/cmap/auth/mongo_credentials'); const { genClusterTime } = require('./common'); const { MongoNetworkError } = require('../../../src/error'); +const { HostAddress } = require('../../../src/utils'); describe('Connect Tests', function () { const test = {}; @@ -15,8 +16,7 @@ describe('Connect Tests', function () { return mock.createServer().then(mockServer => { test.server = mockServer; test.connectOptions = { - host: test.server.host, - port: test.server.port, + hostAddress: test.server.hostAddress(), credentials: new MongoCredentials({ username: 'testUser', password: 'pencil', @@ -92,13 +92,14 @@ describe('Connect Tests', function () { }); it('should emit `MongoNetworkError` for network errors', function (done) { - connect({ host: 'non-existent', port: 27018 }, err => { + connect({ hostAddress: new HostAddress('non-existent:27018') }, err => { expect(err).to.be.instanceOf(MongoNetworkError); done(); }); }); - it('should allow a cancellaton token', { + it.skip('should allow a cancellaton token', { + // FIXME: NODE-2941 metadata: { requires: { os: '!win32' // NODE-2941: 240.0.0.1 doesnt work for windows @@ -109,7 +110,7 @@ describe('Connect Tests', function () { setTimeout(() => cancellationToken.emit('cancel'), 500); // set no response handler for mock server, effecively blackhole requests - connect({ host: '240.0.0.1' }, cancellationToken, (err, conn) => { + connect({ hostAddress: new HostAddress('240.0.0.1'), cancellationToken }, (err, conn) => { expect(err).to.exist; expect(err).to.match(/connection establishment was cancelled/); expect(conn).to.not.exist; diff --git a/test/unit/core/connection_string.test.js b/test/unit/core/connection_string.test.js index 41ee765ff2..1da367a5b6 100644 --- a/test/unit/core/connection_string.test.js +++ b/test/unit/core/connection_string.test.js @@ -1,11 +1,10 @@ 'use strict'; -const { parseConnectionString } = require('../../../src/connection_string'); -const punycode = require('punycode'); const { MongoParseError } = require('../../../src/error'); const { loadSpecTests } = require('../../spec'); const chai = require('chai'); const { parseOptions } = require('../../../src/connection_string'); +const { AuthMechanism } = require('../../../src/cmap/auth/defaultAuthProviders'); const expect = chai.expect; chai.use(require('chai-subset')); @@ -30,197 +29,124 @@ const skipTests = [ ]; describe('Connection String', function () { - it('should support auth passed in through options', function (done) { + it('should not support auth passed with user', function () { const optionsWithUser = { authMechanism: 'SCRAM-SHA-1', auth: { user: 'testing', password: 'llamas' } }; + expect(() => parseOptions('mongodb://localhost', optionsWithUser)).to.throw(MongoParseError); + }); + + it('should support auth passed with username', function () { const optionsWithUsername = { authMechanism: 'SCRAM-SHA-1', auth: { username: 'testing', password: 'llamas' } }; - - parseConnectionString('mongodb://localhost', optionsWithUser, (err, result) => { - expect(err).to.not.exist; - expect(result.auth).to.containSubset({ - db: 'admin', - username: 'testing', - password: 'llamas' - }); - - parseConnectionString('mongodb://localhost', optionsWithUsername, (err, result) => { - expect(err).to.not.exist; - expect(result.auth).to.containSubset({ - db: 'admin', - username: 'testing', - password: 'llamas' - }); - - done(); - }); + const options = parseOptions('mongodb://localhost', optionsWithUsername); + expect(options.credentials).to.containSubset({ + source: 'admin', + username: 'testing', + password: 'llamas' }); }); - it('should provide a default port if one is not provided', function (done) { - parseConnectionString('mongodb://hostname', function (err, result) { - expect(err).to.not.exist; - expect(result.hosts[0].port).to.equal(27017); - done(); - }); + it('should provide a default port if one is not provided', function () { + const options = parseOptions('mongodb://hostname'); + expect(options.hosts[0].socketPath).to.be.undefined; + expect(options.hosts[0].host).to.be.a('string'); + expect(options.hosts[0].port).to.equal(27017); }); - it('should correctly parse arrays', function (done) { - parseConnectionString('mongodb://hostname?foo=bar&foo=baz', function (err, result) { - expect(err).to.not.exist; - expect(result.options.foo).to.deep.equal(['bar', 'baz']); - done(); - }); + it('should parse multiple readPreferenceTags', function () { + const options = parseOptions( + 'mongodb://hostname?readPreferenceTags=bar:foo&readPreferenceTags=baz:bar' + ); + expect(options.readPreference.tags).to.deep.equal([{ bar: 'foo' }, { baz: 'bar' }]); }); - it('should parse boolean values', function (done) { - parseConnectionString('mongodb://hostname?retryWrites=1', function (err, result) { - expect(err).to.not.exist; - expect(result.options.retryWrites).to.equal(false); - - parseConnectionString('mongodb://hostname?retryWrites=false', function (err, result) { - expect(err).to.not.exist; - expect(result.options.retryWrites).to.equal(false); - - parseConnectionString('mongodb://hostname?retryWrites=true', function (err, result) { - expect(err).to.not.exist; - expect(result.options.retryWrites).to.equal(true); - done(); - }); - }); - }); + it('should parse boolean values', function () { + let options = parseOptions('mongodb://hostname?retryWrites=1'); + expect(options.retryWrites).to.equal(true); + options = parseOptions('mongodb://hostname?retryWrites=false'); + expect(options.retryWrites).to.equal(false); + options = parseOptions('mongodb://hostname?retryWrites=t'); + expect(options.retryWrites).to.equal(true); }); - it('should parse compression options', function (done) { - parseConnectionString( - 'mongodb://localhost/?compressors=zlib&zlibCompressionLevel=4', - (err, result) => { - expect(err).to.not.exist; - expect(result.options).to.have.property('compression'); - expect(result.options.compression).to.eql({ - compressors: ['zlib'], - zlibCompressionLevel: 4 - }); - - done(); - } - ); + it('should parse compression options', function () { + const options = parseOptions('mongodb://localhost/?compressors=zlib&zlibCompressionLevel=4'); + expect(options).to.have.property('compressors'); + expect(options.compressors).to.include('zlib'); + expect(options.zlibCompressionLevel).to.equal(4); }); - it('should parse `readConcernLevel`', function (done) { - parseConnectionString('mongodb://localhost/?readConcernLevel=local', (err, result) => { - expect(err).to.not.exist; - expect(result.options).to.have.property('readConcern'); - expect(result.options.readConcern).to.eql({ level: 'local' }); - done(); - }); + it('should parse `readConcernLevel`', function () { + const options = parseOptions('mongodb://localhost/?readConcernLevel=local'); + expect(options).to.have.property('readConcern'); + expect(options.readConcern.level).to.equal('local'); }); - it('should parse `authMechanismProperties`', function (done) { - parseConnectionString( - 'mongodb://user%40EXAMPLE.COM:secret@localhost/?authMechanismProperties=SERVICE_NAME:other,SERVICE_REALM:blah,CANONICALIZE_HOST_NAME:true&authMechanism=GSSAPI', - (err, result) => { - expect(err).to.not.exist; - - const options = result.options; - expect(options).to.deep.include({ - gssapiServiceName: 'other', - gssapiServiceRealm: 'blah', - gssapiCanonicalizeHostName: true - }); - - expect(options).to.have.property('authMechanism'); - expect(options.authMechanism).to.equal('GSSAPI'); - - done(); - } + it('should parse `authMechanismProperties`', function () { + const options = parseOptions( + 'mongodb://user%40EXAMPLE.COM:secret@localhost/?authMechanismProperties=SERVICE_NAME:other,SERVICE_REALM:blah,CANONICALIZE_HOST_NAME:true&authMechanism=GSSAPI' ); - }); - - it('should parse a numeric authSource with variable width', function (done) { - parseConnectionString('mongodb://test@localhost/?authSource=0001', (err, result) => { - expect(err).to.not.exist; - expect(result.options).to.have.property('authSource'); - expect(result.options.authSource).to.equal('0001'); - - done(); + expect(options.credentials.mechanismProperties).to.deep.include({ + SERVICE_NAME: 'other', + SERVICE_REALM: 'blah', + CANONICALIZE_HOST_NAME: true }); + expect(options.credentials.mechanism).to.equal(AuthMechanism.MONGODB_GSSAPI); }); - it('should parse a replicaSet with a leading number', function (done) { - parseConnectionString('mongodb://localhost/?replicaSet=123abc', (err, result) => { - expect(err).to.not.exist; - expect(result.options).to.have.property('replicaSet'); - expect(result.options.replicaSet).to.equal('123abc'); - - done(); - }); + it('should parse a numeric authSource with variable width', function () { + const options = parseOptions('mongodb://test@localhost/?authSource=0001'); + expect(options.credentials.source).to.equal('0001'); }); - it('should parse multiple readPreferenceTags', function (done) { - parseConnectionString( - 'mongodb://localhost/?readPreferenceTags=dc:ny,rack:1&readPreferenceTags=dc:ny', - (err, result) => { - expect(err).to.not.exist; - expect(result.options).to.have.property('readPreferenceTags'); - expect(result.options.readPreferenceTags).to.deep.equal([ - { dc: 'ny', rack: '1' }, - { dc: 'ny' } - ]); - - done(); - } - ); + it('should parse a replicaSet with a leading number', function () { + const options = parseOptions('mongodb://localhost/?replicaSet=123abc'); + expect(options).to.have.property('replicaSet'); + expect(options.replicaSet).to.equal('123abc'); }); describe('validation', function () { - it('should validate compression options', function (done) { - parseConnectionString('mongodb://localhost/?zlibCompressionLevel=15', err => { - expect(err).to.exist; - - parseConnectionString('mongodb://localhost/?compressors=bunnies', err => { - expect(err).to.exist; - - done(); - }); - }); + it('should validate compressors options', function () { + expect(() => parseOptions('mongodb://localhost/?compressors=bunnies')).to.throw( + MongoParseError, + 'bunnies is not a valid compression mechanism' + ); }); - it('should validate authMechanism', function (done) { - parseConnectionString('mongodb://localhost/?authMechanism=DOGS', err => { - expect(err).to.exist; - done(); - }); + it('should validate authMechanism', function () { + expect(() => parseOptions('mongodb://localhost/?authMechanism=DOGS')).to.throw( + MongoParseError, + 'authMechanism one of MONGODB-AWS,MONGODB-CR,DEFAULT,GSSAPI,PLAIN,SCRAM-SHA-1,SCRAM-SHA-256,MONGODB-X509, got DOGS' + ); }); - it('should validate readPreference', function (done) { - parseConnectionString('mongodb://localhost/?readPreference=llamasPreferred', err => { - expect(err).to.exist; - done(); - }); + it('should validate readPreference', function () { + expect(() => parseOptions('mongodb://localhost/?readPreference=llamasPreferred')).to.throw( + TypeError, // not parse Error b/c thrown from ReadPreference construction + 'Invalid read preference mode "llamasPreferred"' + ); }); - it('should validate non-equal tls values', function (done) { - parseConnectionString('mongodb://localhost/?tls=true&tls=false', err => { - expect(err).to.have.property('message', 'All values of tls must be the same.'); - done(); - }); + it('should validate non-equal tls values', function () { + expect(() => parseOptions('mongodb://localhost/?tls=true&tls=false')).to.throw( + MongoParseError, + 'All values of tls must be the same.' + ); }); }); describe('spec tests', function () { - /** @type {import('../../spec/connection-string/valid-auth.json')[]} */ const suites = loadSpecTests('connection-string').concat(loadSpecTests('auth')); for (const suite of suites) { describe(suite.name, function () { for (const test of suite.tests) { - it(`${test.description} -- new MongoOptions parser`, function () { + it(`${test.description}`, function () { if (skipTests.includes(test.description)) { return this.skip(); } @@ -234,12 +160,20 @@ describe('Connection String', function () { if (test.hosts) { for (const [index, { host, port }] of test.hosts.entries()) { - expect(options.hosts[index].host, message).to.equal(host); + expect(options.hosts[index], message).to.satisfy(e => { + return e.host === host || e.socketPath === host; + }); if (typeof port === 'number') expect(options.hosts[index].port).to.equal(port); } } - if (test.auth) { + if (test.auth && test.auth.db != null) { + expect(options.dbName, message).to.equal(test.auth.db); + } + + if (test.auth && test.auth.username) { + expect(options.credentials, message).to.exist; + if (test.auth.db != null) { expect(options.credentials.source, message).to.equal(test.auth.db); } @@ -253,10 +187,28 @@ describe('Connection String', function () { } } - // TODO - // if (test.options) { - // expect(options, message).to.deep.include(test.options); - // } + if (test.options) { + for (const [optionKey, optionValue] of Object.entries(test.options)) { + switch (optionKey) { + case 'authmechanism': + expect(options.credentials.mechanism, message).to.eq(optionValue); + break; + case 'authmechanismproperties': + expect(options.credentials.mechanismProperties, message).to.deep.eq( + optionValue + ); + break; + case 'replicaset': + expect(options.replicaSet, message).to.equal(optionValue); + break; + case 'w': + expect(options.writeConcern.w).to.equal(optionValue); + break; + default: + throw Error(`This options is not covered by the spec test: ${optionKey}`); + } + } + } } else { expect(() => parseOptions(test.uri), message).to.throw(); } @@ -264,78 +216,5 @@ describe('Connection String', function () { } }); } - - suites.forEach(suite => { - describe(suite.name, function () { - suite.tests.forEach(test => { - it(test.description, { - metadata: { requires: { topology: 'single' } }, - test: function (done) { - if (skipTests.indexOf(test.description) !== -1) { - return this.skip(); - } - - const valid = test.valid; - parseConnectionString(test.uri, { caseTranslate: false }, function (err, result) { - if (valid === false) { - expect(err).to.exist; - expect(err).to.be.instanceOf(MongoParseError); - expect(result).to.not.exist; - } else { - expect(err).to.not.exist; - expect(result).to.exist; - - // remove data we don't track - if (test.auth && test.auth.password === '') { - test.auth.password = null; - } - - if (test.hosts != null) { - test.hosts = test.hosts.map(host => { - delete host.type; - host.host = punycode.toASCII(host.host); - return host; - }); - - // remove values that require no validation - test.hosts.forEach(host => { - Object.keys(host).forEach(key => { - if (host[key] == null) delete host[key]; - }); - }); - - expect(result.hosts).to.containSubset(test.hosts); - } - - if (test.auth) { - if (test.auth.db != null) { - expect(result.auth).to.have.property('db'); - expect(result.auth.db).to.eql(test.auth.db); - } - - if (test.auth.username != null) { - expect(result.auth).to.have.property('username'); - expect(result.auth.username).to.eql(test.auth.username); - } - - if (test.auth.password != null) { - expect(result.auth).to.have.property('password'); - expect(result.auth.password).to.eql(test.auth.password); - } - } - - if (test.options != null) { - // it's possible we have options which are not explicitly included in the spec test - expect(result.options).to.deep.include(test.options); - } - } - - done(); - }); - } - }); - }); - }); - }); }); }); diff --git a/test/unit/core/mongodb_srv.test.js b/test/unit/core/mongodb_srv.test.js index 9acd2b9262..a40d7f9252 100644 --- a/test/unit/core/mongodb_srv.test.js +++ b/test/unit/core/mongodb_srv.test.js @@ -1,16 +1,14 @@ 'use strict'; const fs = require('fs'); const path = require('path'); -const parseConnectionString = require('../../../src/connection_string').parseConnectionString; +const { parseOptions, resolveSRVRecord } = require('../../../src/connection_string'); const expect = require('chai').expect; describe('mongodb+srv', function () { - it('should parse a default database', function (done) { - parseConnectionString('mongodb+srv://test1.test.build.10gen.cc/somedb', (err, result) => { - expect(err).to.not.exist; - expect(result.auth.db).to.eql('somedb'); - done(); - }); + it('should parse a default database', function () { + const options = parseOptions('mongodb+srv://test1.test.build.10gen.cc/somedb'); + expect(options.dbName).to.equal('somedb'); + expect(options.srvHost).to.equal('test1.test.build.10gen.cc'); }); describe('spec tests', function () { @@ -29,27 +27,45 @@ describe('mongodb+srv', function () { it(test[1].comment, { metadata: { requires: { topology: ['single'] } }, test: function (done) { - parseConnectionString(test[1].uri, (err, result) => { + try { + const options = parseOptions(test[1].uri); + resolveSRVRecord(options, (err, result) => { + if (test[1].error) { + expect(err).to.exist; + expect(result).to.not.exist; + } else { + expect(err).to.not.exist; + expect(result).to.exist; + // Implicit SRV options must be set. + expect(options.directConnection).to.be.false; + expect(options.tls).to.be.true; + const testOptions = test[1].options; + if (testOptions && testOptions.replicaSet) { + expect(options).to.have.property('replicaSet', testOptions.replicaSet); + } + if (testOptions && testOptions.authSource) { + expect(options).to.have.property('credentials'); + expect(options.credentials.source).to.equal(testOptions.authSource); + } + if ( + test[1].parsed_options && + test[1].parsed_options.user && + test[1].parsed_options.password + ) { + expect(options.credentials.username).to.equal(test[1].parsed_options.user); + expect(options.credentials.password).to.equal(test[1].parsed_options.password); + } + } + done(); + }); + } catch (error) { if (test[1].error) { - expect(err).to.exist; - expect(result).to.not.exist; + expect(error).to.exist; + done(); } else { - expect(err).to.not.exist; - expect(result).to.exist; - if (test[1].options) { - expect(result).property('options').to.matchMongoSpec(test[1].options); - } - if ( - test[1].parsed_options && - test[1].parsed_options.user && - test[1].parsed_options.password - ) { - expect(result.auth.username).to.equal(test[1].parsed_options.user); - expect(result.auth.password).to.equal(test[1].parsed_options.password); - } + throw error; } - done(); - }); + } } }); }); diff --git a/test/unit/core/response_test.js.test.js b/test/unit/core/response_test.js.test.js index 98c16b0dc0..14b34fee73 100644 --- a/test/unit/core/response_test.js.test.js +++ b/test/unit/core/response_test.js.test.js @@ -2,7 +2,7 @@ const expect = require('chai').expect; const { MongoError } = require('../../../src/error'); -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { Topology } = require('../../../src/sdam/topology'); const { Long } = require('bson'); const { MongoDBNamespace } = require('../../../src/utils'); @@ -24,7 +24,7 @@ describe('Response', function () { errmsg: 'Cursor not found (namespace: "liveearth.entityEvents", id: 2018648316188432590).' }; - const client = new Topology(test.server.address()); + const client = new Topology(test.server.hostAddress()); test.server.setMessageHandler(request => { const doc = request.document; diff --git a/test/unit/core/scram_iterations.test.js b/test/unit/core/scram_iterations.test.js index 8c4697954d..ee95897a78 100644 --- a/test/unit/core/scram_iterations.test.js +++ b/test/unit/core/scram_iterations.test.js @@ -1,16 +1,16 @@ 'use strict'; const { expect } = require('chai'); -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { Topology } = require('../../../src/sdam/topology'); const { MongoCredentials } = require('../../../src/cmap/auth/mongo_credentials'); describe('SCRAM Iterations Tests', function () { - const test = {}; + let server; beforeEach(() => { return mock.createServer().then(mockServer => { - test.server = mockServer; + server = mockServer; }); }); @@ -21,7 +21,7 @@ describe('SCRAM Iterations Tests', function () { 'r=IE+xNFeOcslsupAA+zkDVzHd5HfwoRuP7Wi8S4py+erf8PcNm7XIdXQyT52Nj3+M,s=AzomrlMs99A7oFxDLpgFvVb+CSvdyXuNagoWVw==,i=4000'; const credentials = new MongoCredentials({ - mechanism: 'default', + mechanism: 'DEFAULT', source: 'db', username: 'user', password: 'pencil' @@ -32,7 +32,7 @@ describe('SCRAM Iterations Tests', function () { return _done(e); }; - test.server.setMessageHandler(request => { + server.setMessageHandler(request => { const doc = request.document; if (doc.ismaster) { return request.reply(Object.assign({}, mock.DEFAULT_ISMASTER)); @@ -47,7 +47,7 @@ describe('SCRAM Iterations Tests', function () { } }); - const client = new Topology(test.server.uri(), { credentials }); + const client = new Topology(server.hostAddress(), { credentials }); client.on('error', err => { let testErr; try { @@ -67,7 +67,7 @@ describe('SCRAM Iterations Tests', function () { it('should error if server digest is invalid', function (_done) { const credentials = new MongoCredentials({ - mechanism: 'default', + mechanism: 'DEFAULT', source: 'db', username: 'user', password: 'pencil' @@ -78,7 +78,7 @@ describe('SCRAM Iterations Tests', function () { return _done(e); }; - test.server.setMessageHandler(request => { + server.setMessageHandler(request => { const doc = request.document; if (doc.ismaster) { return request.reply(Object.assign({}, mock.DEFAULT_ISMASTER)); @@ -99,7 +99,7 @@ describe('SCRAM Iterations Tests', function () { } }); - const client = new Topology(test.server.uri(), { credentials }); + const client = new Topology(server.hostAddress(), { credentials }); client.on('error', err => { expect(err).to.not.be.null; expect(err) @@ -114,7 +114,7 @@ describe('SCRAM Iterations Tests', function () { it('should properly handle network errors on `saslContinue`', function (_done) { const credentials = new MongoCredentials({ - mechanism: 'default', + mechanism: 'DEFAULT', source: 'db', username: 'user', password: 'pencil' @@ -125,7 +125,7 @@ describe('SCRAM Iterations Tests', function () { return _done(e); }; - test.server.setMessageHandler(request => { + server.setMessageHandler(request => { const doc = request.document; if (doc.ismaster) { return request.reply(Object.assign({}, mock.DEFAULT_ISMASTER)); @@ -142,7 +142,7 @@ describe('SCRAM Iterations Tests', function () { } }); - const client = new Topology(test.server.uri(), { credentials }); + const client = new Topology(server.hostAddress(), { credentials }); client.on('error', err => { expect(err).to.not.be.null; expect(err) diff --git a/test/unit/core/sessions.test.js b/test/unit/core/sessions.test.js index eb2bcef46f..fd8727a798 100644 --- a/test/unit/core/sessions.test.js +++ b/test/unit/core/sessions.test.js @@ -1,6 +1,6 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { expect } = require('chai'); const { genClusterTime, sessionCleanupHandler } = require('./common'); const { Topology } = require('../../../src/sdam/topology'); @@ -30,7 +30,7 @@ describe('Sessions - unit/core', function () { it('should default to `null` for `clusterTime`', { metadata: { requires: { topology: 'single' } }, test: function (done) { - const client = new Topology('localhost:27017'); + const client = new Topology('localhost:27017', {}); const sessionPool = client.s.sessionPool; const session = new ClientSession(client, sessionPool); done = sessionCleanupHandler(session, sessionPool, done); @@ -57,7 +57,7 @@ describe('Sessions - unit/core', function () { describe('ServerSessionPool', function () { afterEach(() => { - test.client.destroy(); + test.client.close(); return mock.cleanup(); }); @@ -76,7 +76,7 @@ describe('Sessions - unit/core', function () { }); }) .then(() => { - test.client = new Topology(test.server.address()); + test.client = new Topology(test.server.hostAddress()); return new Promise((resolve, reject) => { test.client.once('error', reject); diff --git a/test/unit/core/write_concern_error.test.js b/test/unit/core/write_concern_error.test.js index 74c8e12b0b..a2d2b56b8f 100644 --- a/test/unit/core/write_concern_error.test.js +++ b/test/unit/core/write_concern_error.test.js @@ -1,6 +1,6 @@ 'use strict'; const { Topology } = require('../../../src/sdam/topology'); -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { ReplSetFixture } = require('./common'); const { MongoWriteConcernError } = require('../../../src/error'); const { expect } = require('chai'); @@ -12,7 +12,7 @@ describe('WriteConcernError', function () { createUser: 'foo2', pwd: 'pwd', roles: ['read'], - writeConcern: { w: 'majority', wtimeout: 1 } + writeConcern: { w: 'majority', wtimeoutMS: 1 } }; const RAW_USER_WRITE_CONCERN_ERROR = { @@ -56,7 +56,7 @@ describe('WriteConcernError', function () { function makeAndConnectReplSet(cb) { let invoked = false; const replSet = new Topology( - [test.primaryServer.address(), test.firstSecondaryServer.address()], + [test.primaryServer.hostAddress(), test.firstSecondaryServer.hostAddress()], { replicaSet: 'rs' } ); diff --git a/test/unit/create_index_error.test.js b/test/unit/create_index_error.test.js index c7b027d25a..9459bf7005 100644 --- a/test/unit/create_index_error.test.js +++ b/test/unit/create_index_error.test.js @@ -1,7 +1,7 @@ 'use strict'; const expect = require('chai').expect; -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); describe('CreateIndexError', function () { const test = {}; diff --git a/test/unit/db_list_collections.test.js b/test/unit/db_list_collections.test.js index 87d0d596ea..9996c94974 100644 --- a/test/unit/db_list_collections.test.js +++ b/test/unit/db_list_collections.test.js @@ -1,6 +1,6 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../tools/mock'); const expect = require('chai').expect; describe('db.listCollections', function () { diff --git a/test/unit/mongo_client_options.test.js b/test/unit/mongo_client_options.test.js index 74b46fd86a..10116eefef 100644 --- a/test/unit/mongo_client_options.test.js +++ b/test/unit/mongo_client_options.test.js @@ -1,5 +1,6 @@ 'use strict'; - +const os = require('os'); +const fs = require('fs'); const { expect } = require('chai'); const { parseOptions } = require('../../src/connection_string'); const { ReadConcern } = require('../../src/read_concern'); @@ -7,10 +8,12 @@ const { WriteConcern } = require('../../src/write_concern'); const { ReadPreference } = require('../../src/read_preference'); const { Logger } = require('../../src/logger'); const { MongoCredentials } = require('../../src/cmap/auth/mongo_credentials'); +const { MongoClient } = require('../../src'); describe('MongoOptions', function () { - it('parseOptions should always return frozen object', function () { - expect(parseOptions('mongodb://localhost:27017')).to.be.frozen; + it('MongoClient should always freeze public options', function () { + const client = new MongoClient('mongodb://localhost:27017'); + expect(client.options).to.be.frozen; }); it('test simple', function () { @@ -23,15 +26,18 @@ describe('MongoOptions', function () { expect(options.prototype).to.not.exist; }); - it('tls renames', function () { + it('should rename tls options correctly', function () { + const filename = `${os.tmpdir()}/tmp.pem`; + fs.closeSync(fs.openSync(filename, 'w')); const options = parseOptions('mongodb://localhost:27017/?ssl=true', { - tlsCertificateKeyFile: [{ pem: 'pem' }, { pem: 'pem2', passphrase: 'passphrase' }], - tlsCertificateFile: 'tlsCertificateFile', - tlsCAFile: 'tlsCAFile', - sslCRL: 'sslCRL', + tlsCertificateKeyFile: filename, + tlsCertificateFile: filename, + tlsCAFile: filename, + sslCRL: filename, tlsCertificateKeyFilePassword: 'tlsCertificateKeyFilePassword', sslValidate: false }); + fs.unlinkSync(filename); /* * If set TLS enabled, equivalent to setting the ssl option. @@ -52,11 +58,11 @@ describe('MongoOptions', function () { expect(options).to.not.have.property('tlsCAFile'); expect(options).to.not.have.property('sslCRL'); expect(options).to.not.have.property('tlsCertificateKeyFilePassword'); - expect(options).has.property('ca', 'tlsCAFile'); - expect(options).has.property('crl', 'sslCRL'); - expect(options).has.property('cert', 'tlsCertificateFile'); + expect(options).has.property('ca', ''); + expect(options).has.property('crl', ''); + expect(options).has.property('cert', ''); expect(options).has.property('key'); - expect(options.key).has.length(2); + expect(options.key).has.length(0); expect(options).has.property('passphrase', 'tlsCertificateKeyFilePassword'); expect(options).has.property('rejectUnauthorized', false); expect(options).has.property('tls', true); @@ -71,7 +77,6 @@ describe('MongoOptions', function () { autoEncryption: { bypassAutoEncryption: true }, checkKeys: true, checkServerIdentity: false, - compression: 'zlib', compressors: 'snappy', // TODO connectTimeoutMS: 123, directConnection: true, @@ -97,7 +102,6 @@ describe('MongoOptions', function () { minPoolSize: 1, monitorCommands: true, noDelay: true, - numberOfRetries: 3, pkFactory: { createPk() { return 'very unique'; @@ -117,23 +121,15 @@ describe('MongoOptions', function () { retryWrites: true, serializeFunctions: true, serverSelectionTimeoutMS: 3, - serverSelectionTryOnce: true, servername: 'some tls option', socketTimeoutMS: 3, ssl: true, - sslCA: 'ca', - sslCert: 'cert', - sslCRL: 'crl', - sslKey: 'key', sslPass: 'pass', sslValidate: true, tls: false, tlsAllowInvalidCertificates: true, tlsAllowInvalidHostnames: true, - tlsCAFile: 'tls-ca', - tlsCertificateKeyFile: 'tls-key', tlsCertificateKeyFilePassword: 'tls-pass', - // tlsInsecure: true, w: 'majority', waitQueueTimeoutMS: 3, writeConcern: new WriteConcern(2), @@ -175,18 +171,12 @@ describe('MongoOptions', function () { 'retryReads=true', 'retryWrites=true', 'serverSelectionTimeoutMS=2', - 'serverSelectionTryOnce=true', 'socketTimeoutMS=2', 'ssl=true', 'tls=true', 'tlsAllowInvalidCertificates=true', 'tlsAllowInvalidHostnames=true', - 'tlsCAFile=CA_FILE', - 'tlsCertificateKeyFile=KEY_FILE', 'tlsCertificateKeyFilePassword=PASSWORD', - // 'tlsDisableCertificateRevocationCheck=true', // not implemented - // 'tlsDisableOCSPEndpointCheck=true', // not implemented - // 'tlsInsecure=true', 'w=majority', 'waitQueueTimeoutMS=2', 'wTimeoutMS=2', @@ -202,9 +192,9 @@ describe('MongoOptions', function () { expect(options.writeConcern).has.property('wtimeout', 2); }); - it('srv', function () { + it('srvHost saved to options for later resolution', function () { const options = parseOptions('mongodb+srv://server.example.com/'); - expect(options).has.property('srv', true); + expect(options).has.property('srvHost', 'server.example.com'); }); it('supports ReadPreference option in url', function () { @@ -287,23 +277,20 @@ describe('MongoOptions', function () { expect(options.credentials).to.be.an.instanceof(MongoCredentials); expect(options.credentials.username).to.equal('USERNAME'); expect(options.credentials.password).to.equal('PASSWORD'); + expect(options.credentials.source).to.equal('admin'); }); - it('supports Credentials option in auth object plain', function () { - const options = parseOptions('mongodb://localhost/', { - auth: { username: 'USERNAME', password: 'PASSWORD' } - }); + it('supports Credentials option in url with db', function () { + const options = parseOptions('mongodb://USERNAME:PASSWORD@localhost/foo'); expect(options.credentials).to.be.an.instanceof(MongoCredentials); expect(options.credentials.username).to.equal('USERNAME'); expect(options.credentials.password).to.equal('PASSWORD'); + expect(options.credentials.source).to.equal('foo'); }); - it('supports Credentials option in object plain', function () { - // top-level username and password are supported because - // they represent the authority section of connection string + it('supports Credentials option in auth object plain', function () { const options = parseOptions('mongodb://localhost/', { - username: 'USERNAME', - password: 'PASSWORD' + auth: { username: 'USERNAME', password: 'PASSWORD' } }); expect(options.credentials).to.be.an.instanceof(MongoCredentials); expect(options.credentials.username).to.equal('USERNAME'); diff --git a/test/unit/optional_require.test.js b/test/unit/optional_require.test.js index 6ae53ed241..3c270cd754 100644 --- a/test/unit/optional_require.test.js +++ b/test/unit/optional_require.test.js @@ -7,6 +7,7 @@ const { compress } = require('../../src/cmap/wire_protocol/compression'); const { GSSAPI } = require('../../src/cmap/auth/gssapi'); const { AuthContext } = require('../../src/cmap/auth/auth_provider'); const { MongoDBAWS } = require('../../src/cmap/auth/mongodb_aws'); +const { HostAddress } = require('../../src/utils'); function moduleExistsSync(moduleName) { return existsSync(resolve(__dirname, `../../node_modules/${moduleName}`)); @@ -41,10 +42,13 @@ describe('optionalRequire', function () { return this.skip(); } const gssapi = new GSSAPI(); - gssapi.auth(new AuthContext(null, true, { host: true, port: true }), error => { - expect(error).to.exist; - expect(error.message).includes('not found'); - }); + gssapi.auth( + new AuthContext(null, true, { hostAddress: new HostAddress('a'), credentials: true }), + error => { + expect(error).to.exist; + expect(error.message).includes('not found'); + } + ); }); }); diff --git a/test/unit/sdam/monitoring.test.js b/test/unit/sdam/monitoring.test.js index 1c161900a1..d6b660647f 100644 --- a/test/unit/sdam/monitoring.test.js +++ b/test/unit/sdam/monitoring.test.js @@ -1,5 +1,5 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { ServerType } = require('../../../src/sdam/common'); const { Topology } = require('../../../src/sdam/topology'); const { Monitor } = require('../../../src/sdam/monitor'); @@ -8,7 +8,7 @@ const { ServerDescription } = require('../../../src/sdam/server_description'); class MockServer { constructor(options) { - this.s = {}; + this.s = { pool: { generation: 1 } }; this.description = new ServerDescription(`${options.host}:${options.port}`); this.description.type = ServerType.Unknown; } @@ -33,7 +33,7 @@ describe('monitoring', function () { }); // set `heartbeatFrequencyMS` to 250ms to force a quick monitoring check, and wait 500ms to validate below - const topology = new Topology(mockServer.uri(), { heartbeatFrequencyMS: 250 }); + const topology = new Topology(mockServer.hostAddress(), { heartbeatFrequencyMS: 250 }); topology.connect(err => { expect(err).to.not.exist; @@ -74,7 +74,7 @@ describe('monitoring', function () { acceptConnections = true; }, 250); - const topology = new Topology(mockServer.uri()); + const topology = new Topology(mockServer.hostAddress(), {}); topology.connect(err => { expect(err).to.not.exist; expect(topology).property('description').property('servers').to.have.length(1); @@ -181,7 +181,7 @@ describe('monitoring', function () { }); const server = new MockServer(mockServer.address()); - server.description = new ServerDescription(server.description.address); + server.description = new ServerDescription(server.description.hostAddress); const monitor = new Monitor(server, { heartbeatFrequencyMS: 250, minHeartbeatFrequencyMS: 50 diff --git a/test/unit/sdam/server_description.test.js b/test/unit/sdam/server_description.test.js index 8589bbddbd..4231a10a11 100644 --- a/test/unit/sdam/server_description.test.js +++ b/test/unit/sdam/server_description.test.js @@ -43,7 +43,7 @@ describe('ServerDescription', function () { }); it('should sensibly parse an ipv6 address', function () { - const description = new ServerDescription('abcd:f::abcd:abcd:abcd:abcd:27017'); + const description = new ServerDescription('[ABCD:f::abcd:abcd:abcd:abcd]:27017'); expect(description.host).to.equal('abcd:f::abcd:abcd:abcd:abcd'); expect(description.port).to.equal(27017); }); diff --git a/test/unit/sdam/server_selection/spec.test.js b/test/unit/sdam/server_selection/spec.test.js index 02bcf54300..aa4c068a14 100644 --- a/test/unit/sdam/server_selection/spec.test.js +++ b/test/unit/sdam/server_selection/spec.test.js @@ -142,24 +142,6 @@ describe('Max Staleness (spec)', function () { }); }); -function normalizeSeed(seed) { - let host = seed; - let port = 27017; - - // is this a host + port combo? - if (seed.indexOf(':') !== -1) { - host = seed.split(':')[0]; - port = parseInt(seed.split(':')[1], 10); - } - - // support IPv6 - if (host.startsWith('[')) { - host = host.slice(1, host.length - 1); - } - - return { host, port }; -} - function serverDescriptionFromDefinition(definition, hosts) { hosts = hosts || []; @@ -223,7 +205,7 @@ function executeServerSelectionTest(testDefinition, options, testDone) { const topologyDescription = testDefinition.topology_description; const seedData = topologyDescription.servers.reduce( (result, seed) => { - result.seedlist.push(normalizeSeed(seed.address)); + result.seedlist.push(seed.address); result.hosts.push(seed.address); return result; }, diff --git a/test/unit/sdam/spec.test.js b/test/unit/sdam/spec.test.js index 2055c860e9..a265c94f34 100644 --- a/test/unit/sdam/spec.test.js +++ b/test/unit/sdam/spec.test.js @@ -5,7 +5,7 @@ const { Topology } = require('../../../src/sdam/topology'); const { Server } = require('../../../src/sdam/server'); const { ServerDescription } = require('../../../src/sdam/server_description'); const sdamEvents = require('../../../src/sdam/events'); -const parse = require('../../../src/connection_string').parseConnectionString; +const { parseOptions } = require('../../../src/connection_string'); const sinon = require('sinon'); const { EJSON } = require('bson'); const { ConnectionPool } = require('../../../src/cmap/connection_pool'); @@ -171,112 +171,107 @@ function cloneForCompare(event) { } function executeSDAMTest(testData, testDone) { - parse(testData.uri, (err, parsedUri) => { - if (err) return done(err); - - // create the topology - const topology = new Topology(parsedUri.hosts, parsedUri.options); - - // Each test will attempt to connect by doing server selection. We want to make the first - // call to `selectServers` call a fake, and then immediately restore the original behavior. - let topologySelectServers = sinon - .stub(Topology.prototype, 'selectServer') - .callsFake(function (selector, options, callback) { - topologySelectServers.restore(); + const options = parseOptions(testData.uri); + // create the topology + const topology = new Topology(options.hosts, options); + // Each test will attempt to connect by doing server selection. We want to make the first + // call to `selectServers` call a fake, and then immediately restore the original behavior. + let topologySelectServers = sinon + .stub(Topology.prototype, 'selectServer') + .callsFake(function (selector, options, callback) { + topologySelectServers.restore(); + + const fakeServer = { s: { state: 'connected' }, removeListener: () => {} }; + callback(undefined, fakeServer); + }); + // listen for SDAM monitoring events + let events = []; + [ + 'serverOpening', + 'serverClosed', + 'serverDescriptionChanged', + 'topologyOpening', + 'topologyClosed', + 'topologyDescriptionChanged', + 'serverHeartbeatStarted', + 'serverHeartbeatSucceeded', + 'serverHeartbeatFailed' + ].forEach(eventName => { + topology.on(eventName, event => events.push(event)); + }); - const fakeServer = { s: { state: 'connected' }, removeListener: () => {} }; - callback(undefined, fakeServer); - }); + function done(err) { + topology.close(e => testDone(e || err)); + } - // listen for SDAM monitoring events - let events = []; - [ - 'serverOpening', - 'serverClosed', - 'serverDescriptionChanged', - 'topologyOpening', - 'topologyClosed', - 'topologyDescriptionChanged', - 'serverHeartbeatStarted', - 'serverHeartbeatSucceeded', - 'serverHeartbeatFailed' - ].forEach(eventName => { - topology.on(eventName, event => events.push(event)); - }); + const incompatibilityHandler = err => { + if (err.message.match(/but this version of the driver/)) return; + throw err; + }; - function done(err) { - topology.close(e => testDone(e || err)); - } + // connect the topology + topology.connect(options, err => { + expect(err).to.not.exist; - const incompatibilityHandler = err => { - if (err.message.match(/but this version of the driver/)) return; - throw err; - }; + eachAsyncSeries( + testData.phases, + (phase, cb) => { + function phaseDone() { + if (phase.outcome) { + assertOutcomeExpectations(topology, events, phase.outcome); + } - // connect the topology - topology.connect(testData.uri, err => { - expect(err).to.not.exist; + // remove error handler + topology.removeListener('error', incompatibilityHandler); + // reset the captured events for each phase + events = []; + cb(); + } - eachAsyncSeries( - testData.phases, - (phase, cb) => { - function phaseDone() { - if (phase.outcome) { - assertOutcomeExpectations(topology, events, phase.outcome); - } + const incompatibilityExpected = phase.outcome ? !phase.outcome.compatible : false; + if (incompatibilityExpected) { + topology.on('error', incompatibilityHandler); + } - // remove error handler - topology.removeListener('error', incompatibilityHandler); - // reset the captured events for each phase - events = []; - cb(); - } + // if (phase.description) { + // console.log(`[phase] ${phase.description}`); + // } - const incompatibilityExpected = phase.outcome ? !phase.outcome.compatible : false; - if (incompatibilityExpected) { - topology.on('error', incompatibilityHandler); - } + if (phase.responses) { + // simulate each ismaster response + phase.responses.forEach(response => + topology.serverUpdateHandler(new ServerDescription(response[0], response[1])) + ); - // if (phase.description) { - // console.log(`[phase] ${phase.description}`); - // } - - if (phase.responses) { - // simulate each ismaster response - phase.responses.forEach(response => - topology.serverUpdateHandler(new ServerDescription(response[0], response[1])) - ); - - phaseDone(); - } else if (phase.applicationErrors) { - eachAsyncSeries( - phase.applicationErrors, - (appError, phaseCb) => { - let withConnectionStub = sinon - .stub(ConnectionPool.prototype, 'withConnection') - .callsFake(withConnectionStubImpl(appError)); - - const server = topology.s.servers.get(appError.address); - server.command(ns('admin.$cmd'), { ping: 1 }, undefined, err => { - expect(err).to.exist; - withConnectionStub.restore(); - - phaseCb(); - }); - }, - err => { - expect(err).to.not.exist; - phaseDone(); - } - ); - } - }, - err => { - expect(err).to.not.exist; - done(); + phaseDone(); + } else if (phase.applicationErrors) { + eachAsyncSeries( + phase.applicationErrors, + (appError, phaseCb) => { + let withConnectionStub = sinon + .stub(ConnectionPool.prototype, 'withConnection') + .callsFake(withConnectionStubImpl(appError)); + + const server = topology.s.servers.get(appError.address); + server.command(ns('admin.$cmd'), { ping: 1 }, undefined, err => { + expect(err).to.exist; + withConnectionStub.restore(); + + phaseCb(); + }); + }, + err => { + expect(err).to.not.exist; + phaseDone(); + } + ); } - ); - }); + }, + err => { + expect(err).to.not.exist; + done(); + } + ); }); } diff --git a/test/unit/sdam/srv_polling.test.js b/test/unit/sdam/srv_polling.test.js index b405ca64ed..a007b9678d 100644 --- a/test/unit/sdam/srv_polling.test.js +++ b/test/unit/sdam/srv_polling.test.js @@ -10,7 +10,8 @@ const dns = require('dns'); const EventEmitter = require('events').EventEmitter; const chai = require('chai'); const sinon = require('sinon'); -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); +const { HostAddress } = require('../../../src/utils'); const expect = chai.expect; chai.use(require('sinon-chai')); @@ -242,7 +243,9 @@ describe('Mongos SRV Polling', function () { it('should not make an srv poller if there is no srv host', function () { const srvPoller = new FakeSrvPoller({ srvHost: SRV_HOST }); - const topology = new Topology(['localhost:27017,localhost:27018'], { srvPoller }); + const topology = new Topology(['localhost:27017', 'localhost:27018'], { + srvPoller + }); expect(topology).to.not.have.property('srvPoller'); }); @@ -250,7 +253,7 @@ describe('Mongos SRV Polling', function () { it('should make an srvPoller if there is an srvHost', function () { const srvPoller = new FakeSrvPoller({ srvHost: SRV_HOST }); - const topology = new Topology(['localhost:27017,localhost:27018'], { + const topology = new Topology(['localhost:27017', 'localhost:27018'], { srvHost: SRV_HOST, srvPoller }); @@ -262,7 +265,7 @@ describe('Mongos SRV Polling', function () { const srvPoller = new FakeSrvPoller({ srvHost: SRV_HOST }); sinon.stub(srvPoller, 'start'); - const topology = new Topology(['localhost:27017,localhost:27018'], { + const topology = new Topology(['localhost:27017', 'localhost:27018'], { srvHost: SRV_HOST, srvPoller }); @@ -331,7 +334,9 @@ describe('Mongos SRV Polling', function () { }); const srvPoller = new FakeSrvPoller({ srvHost: SRV_HOST }); - const seedlist = recordSets[0].map(record => `${record.name}:${record.port}`).join(','); + const seedlist = recordSets[0].map(record => + HostAddress.fromString(`${record.name}:${record.port}`) + ); context.topology = new Topology(seedlist, { srvPoller, srvHost: SRV_HOST }); const topology = context.topology; diff --git a/test/unit/sdam/topology.test.js b/test/unit/sdam/topology.test.js index 401e4c8caa..0f46e7a6e6 100644 --- a/test/unit/sdam/topology.test.js +++ b/test/unit/sdam/topology.test.js @@ -1,12 +1,12 @@ 'use strict'; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const { expect } = require('chai'); const sinon = require('sinon'); const { Topology } = require('../../../src/sdam/topology'); const { Server } = require('../../../src/sdam/server'); const { ServerDescription } = require('../../../src/sdam/server_description'); -const { ns } = require('../../../src/utils'); +const { ns, makeClientMetadata } = require('../../../src/utils'); describe('Topology (unit)', function () { describe('client metadata', function () { @@ -19,12 +19,11 @@ describe('Topology (unit)', function () { test: function (done) { // Attempt to connect - var server = new Topology( - [{ host: this.configuration.host, port: this.configuration.port }], - { - appname: 'My application name' - } - ); + var server = new Topology([`${this.configuration.host}:${this.configuration.port}`], { + metadata: makeClientMetadata({ + appName: 'My application name' + }) + }); expect(server.clientMetadata.application.name).to.equal('My application name'); done(); @@ -118,7 +117,7 @@ describe('Topology (unit)', function () { }); it('should check for sessions if there are no data-bearing nodes', function (done) { - const topology = new Topology('mongos:27019,mongos:27018,mongos:27017'); + const topology = new Topology(['mongos:27019', 'mongos:27018', 'mongos:27017'], {}); this.sinon.stub(Server.prototype, 'connect').callsFake(function () { this.s.state = 'connected'; this.emit('connect'); @@ -156,7 +155,7 @@ describe('Topology (unit)', function () { } }); - const topology = new Topology(mockServer.uri()); + const topology = new Topology(mockServer.hostAddress()); topology.connect(err => { expect(err).to.not.exist; @@ -192,7 +191,7 @@ describe('Topology (unit)', function () { } }); - const topology = new Topology(mockServer.uri()); + const topology = new Topology(mockServer.hostAddress()); topology.connect(err => { expect(err).to.not.exist; @@ -229,7 +228,7 @@ describe('Topology (unit)', function () { } }); - const topology = new Topology(mockServer.uri()); + const topology = new Topology(mockServer.hostAddress()); topology.connect(err => { expect(err).to.not.exist; @@ -266,7 +265,7 @@ describe('Topology (unit)', function () { } }); - const topology = new Topology(mockServer.uri()); + const topology = new Topology(mockServer.hostAddress()); topology.connect(err => { expect(err).to.not.exist; diff --git a/test/unit/sessions/client.test.js b/test/unit/sessions/client.test.js index 9d56a3db3d..2cd4c09101 100644 --- a/test/unit/sessions/client.test.js +++ b/test/unit/sessions/client.test.js @@ -1,7 +1,7 @@ 'use strict'; const expect = require('chai').expect; -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const test = {}; describe('Sessions - client/unit', function () { diff --git a/test/unit/sessions/collection.test.js b/test/unit/sessions/collection.test.js index 44f4f4ff31..548cdcb9ac 100644 --- a/test/unit/sessions/collection.test.js +++ b/test/unit/sessions/collection.test.js @@ -1,7 +1,7 @@ 'use strict'; const { Timestamp } = require('bson'); const { expect } = require('chai'); -const mock = require('mongodb-mock-server'); +const mock = require('../../tools/mock'); const test = {}; describe('Sessions - unit/sessions', function () {