diff --git a/src/cmap/auth/defaultAuthProviders.ts b/src/cmap/auth/defaultAuthProviders.ts index 7f7b801bd5..0d9d77321c 100644 --- a/src/cmap/auth/defaultAuthProviders.ts +++ b/src/cmap/auth/defaultAuthProviders.ts @@ -7,16 +7,19 @@ import { MongoDBAWS } from './mongodb_aws'; import type { AuthProvider } from './auth_provider'; /** @public */ -export enum AuthMechanism { - MONGODB_AWS = 'MONGODB-AWS', - MONGODB_CR = 'MONGODB-CR', - MONGODB_DEFAULT = 'DEFAULT', - MONGODB_GSSAPI = 'GSSAPI', - MONGODB_PLAIN = 'PLAIN', - MONGODB_SCRAM_SHA1 = 'SCRAM-SHA-1', - MONGODB_SCRAM_SHA256 = 'SCRAM-SHA-256', - MONGODB_X509 = 'MONGODB-X509' -} +export const AuthMechanism = { + MONGODB_AWS: 'MONGODB-AWS', + MONGODB_CR: 'MONGODB-CR', + MONGODB_DEFAULT: 'DEFAULT', + MONGODB_GSSAPI: 'GSSAPI', + MONGODB_PLAIN: 'PLAIN', + MONGODB_SCRAM_SHA1: 'SCRAM-SHA-1', + MONGODB_SCRAM_SHA256: 'SCRAM-SHA-256', + MONGODB_X509: 'MONGODB-X509' +} as const; + +/** @public */ +export type AuthMechanismId = typeof AuthMechanism[keyof typeof AuthMechanism]; export const AUTH_PROVIDERS = { [AuthMechanism.MONGODB_AWS]: new MongoDBAWS(), diff --git a/src/cmap/auth/mongo_credentials.ts b/src/cmap/auth/mongo_credentials.ts index 7f28fab78e..748a19b1fd 100644 --- a/src/cmap/auth/mongo_credentials.ts +++ b/src/cmap/auth/mongo_credentials.ts @@ -1,15 +1,15 @@ // Resolves the default auth mechanism according to import type { Document } from '../../bson'; -import { AuthMechanism } from './defaultAuthProviders'; +import { AuthMechanismId, AuthMechanism } from './defaultAuthProviders'; // https://github.com/mongodb/specifications/blob/master/source/auth/auth.rst -function getDefaultAuthMechanism(ismaster?: Document): AuthMechanism { +function getDefaultAuthMechanism(ismaster?: Document): AuthMechanismId { if (ismaster) { // If ismaster contains saslSupportedMechs, use scram-sha-256 // if it is available, else scram-sha-1 if (Array.isArray(ismaster.saslSupportedMechs)) { - return ismaster.saslSupportedMechs.indexOf('SCRAM-SHA-256') >= 0 + return ismaster.saslSupportedMechs.includes(AuthMechanism.MONGODB_SCRAM_SHA256) ? AuthMechanism.MONGODB_SCRAM_SHA256 : AuthMechanism.MONGODB_SCRAM_SHA1; } @@ -30,7 +30,7 @@ export interface MongoCredentialsOptions { password: string; source: string; db?: string; - mechanism?: AuthMechanism; + mechanism?: AuthMechanismId; mechanismProperties: Document; } @@ -46,7 +46,7 @@ export class MongoCredentials { /** The database that the user should authenticate against */ readonly source: string; /** The method used to authenticate */ - readonly mechanism: AuthMechanism; + readonly mechanism: AuthMechanismId; /** Special properties used by some types of auth mechanisms */ readonly mechanismProperties: Document; @@ -108,4 +108,54 @@ export class MongoCredentials { return this; } + + validate(): void { + if ( + (this.mechanism === AuthMechanism.MONGODB_GSSAPI || + this.mechanism === AuthMechanism.MONGODB_CR || + this.mechanism === AuthMechanism.MONGODB_PLAIN || + this.mechanism === AuthMechanism.MONGODB_SCRAM_SHA1 || + this.mechanism === AuthMechanism.MONGODB_SCRAM_SHA256) && + !this.username + ) { + throw new TypeError(`Username required for mechanism '${this.mechanism}'`); + } + + if ( + this.mechanism === AuthMechanism.MONGODB_GSSAPI || + this.mechanism === AuthMechanism.MONGODB_AWS || + this.mechanism === AuthMechanism.MONGODB_X509 + ) { + if (this.source != null && this.source !== '$external') { + throw new TypeError( + `Invalid source '${this.source}' for mechanism '${this.mechanism}' specified.` + ); + } + } + + if (this.mechanism === AuthMechanism.MONGODB_PLAIN && this.source == null) { + throw new TypeError('PLAIN Authentication Mechanism needs an auth source'); + } + + if (this.mechanism === AuthMechanism.MONGODB_X509 && this.password != null) { + if (this.password === '') { + Reflect.set(this, 'password', undefined); + return; + } + throw new TypeError(`Password not allowed for mechanism MONGODB-X509`); + } + } + + static merge( + creds: MongoCredentials, + 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 + }); + } } diff --git a/src/cmap/auth/scram.ts b/src/cmap/auth/scram.ts index 125b192b6f..28ab56d540 100644 --- a/src/cmap/auth/scram.ts +++ b/src/cmap/auth/scram.ts @@ -7,6 +7,7 @@ import type { MongoCredentials } from './mongo_credentials'; import type { HandshakeDocument } from '../connect'; import { saslprep } from '../../deps'; +import { AuthMechanism } from './defaultAuthProviders'; type CryptoMethod = 'sha1' | 'sha256'; @@ -83,7 +84,8 @@ function makeFirstMessage( nonce: Buffer ) { const username = cleanUsername(credentials.username); - const mechanism = cryptoMethod === 'sha1' ? 'SCRAM-SHA-1' : 'SCRAM-SHA-256'; + const mechanism = + cryptoMethod === 'sha1' ? AuthMechanism.MONGODB_SCRAM_SHA1 : AuthMechanism.MONGODB_SCRAM_SHA256; // NOTE: This is done b/c Javascript uses UTF-16, but the server is hashing in UTF-8. // Since the username is not sasl-prep-d, we need to do this here. diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 1af62ab77f..fb8a3356ab 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -27,10 +27,10 @@ import type { Server } from '../sdam/server'; import type { MongoCredentials } from './auth/mongo_credentials'; import type { CommandOptions } from './wire_protocol/command'; import type { GetMoreOptions } from './wire_protocol/get_more'; -import type { InsertOptions, UpdateOptions, RemoveOptions } from './wire_protocol/index'; import type { Stream } from './connect'; import type { LoggerOptions } from '../logger'; import type { QueryOptions } from './wire_protocol/query'; +import type { WriteCommandOptions } from './wire_protocol/write_command'; const kStream = Symbol('stream'); const kQueue = Symbol('queue'); @@ -280,17 +280,17 @@ export class Connection extends EventEmitter { } /** @internal */ - insert(ns: string, ops: Document[], options: InsertOptions, callback: Callback): void { + insert(ns: string, ops: Document[], options: WriteCommandOptions, callback: Callback): void { wp.insert(makeServerTrampoline(this), ns, ops, options, callback); } /** @internal */ - update(ns: string, ops: Document[], options: UpdateOptions, callback: Callback): void { + update(ns: string, ops: Document[], options: WriteCommandOptions, callback: Callback): void { wp.update(makeServerTrampoline(this), ns, ops, options, callback); } /** @internal */ - remove(ns: string, ops: Document[], options: RemoveOptions, callback: Callback): void { + remove(ns: string, ops: Document[], options: WriteCommandOptions, callback: Callback): void { wp.remove(makeServerTrampoline(this), ns, ops, options, callback); } } diff --git a/src/cmap/wire_protocol/index.ts b/src/cmap/wire_protocol/index.ts index 3ae1230548..1ebb40826a 100644 --- a/src/cmap/wire_protocol/index.ts +++ b/src/cmap/wire_protocol/index.ts @@ -11,40 +11,31 @@ import type { Callback } from '../../utils'; export { writeCommand }; -/** @internal */ -export type InsertOptions = WriteCommandOptions; - export function insert( server: Server, ns: string, ops: Document[], - options: InsertOptions, + options: WriteCommandOptions, callback: Callback ): void { writeCommand(server, 'insert', 'documents', ns, ops, options, callback); } -/** @internal */ -export type UpdateOptions = WriteCommandOptions; - export function update( server: Server, ns: string, ops: Document[], - options: UpdateOptions, + options: WriteCommandOptions, callback: Callback ): void { writeCommand(server, 'update', 'updates', ns, ops, options, callback); } -/** @internal */ -export type RemoveOptions = WriteCommandOptions; - export function remove( server: Server, ns: string, ops: Document[], - options: RemoveOptions, + options: WriteCommandOptions, callback: Callback ): void { writeCommand(server, 'delete', 'deletes', ns, ops, options, callback); diff --git a/src/connection_string.ts b/src/connection_string.ts index fa3ee8dd4d..5307776d02 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -2,13 +2,26 @@ import * as url from 'url'; import * as qs from 'querystring'; import * as dns from 'dns'; import { URL } from 'url'; -import { ReadPreference } from './read_preference'; +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 type { AnyOptions, Callback } from './utils'; +import { AnyOptions, Callback, isRecord } from './utils'; import type { ConnectionOptions } from './cmap/connection'; import type { Document } from './bson'; import type { CompressorName } from './cmap/wire_protocol/compression'; -import type { MongoClientOptions, MongoOptions } from './mongo_client'; +import type { + DriverInfo, + HostAddress, + MongoClientOptions, + MongoOptions, + PkFactory +} from './mongo_client'; +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 @@ -175,17 +188,7 @@ const BOOLEAN_OPTIONS = new Set([ const STRING_OPTIONS = new Set(['authsource', 'replicaset']); // Supported text representations of auth mechanisms -// NOTE: this list exists in native already, if it is merged here we should deduplicate -const AUTH_MECHANISMS = new Set([ - 'GSSAPI', - 'MONGODB-AWS', - 'MONGODB-X509', - 'MONGODB-CR', - 'DEFAULT', - 'SCRAM-SHA-1', - 'SCRAM-SHA-256', - 'PLAIN' -]); +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 @@ -501,32 +504,19 @@ function checkTLSQueryString(queryString: any) { } } -/** - * Checks options object if both options are present (any value) will throw an error. - * - * @param options - The options used for options parsing - * @param optionKeyA - A options key - * @param optionKeyB - B options key - * @throws MongoParseError if two provided options are mutually exclusive. - */ -function assertRepelOptions(options: AnyOptions, optionKeyA: string, optionKeyB: string) { - if ( - Object.prototype.hasOwnProperty.call(options, optionKeyA) && - Object.prototype.hasOwnProperty.call(options, optionKeyB) - ) { - throw new MongoParseError(`The \`${optionKeyA}\` option cannot be used with \`${optionKeyB}\``); - } -} - /** * Checks if TLS options are valid * * @param options - The options used for options parsing * @throws MongoParseError if TLS options are invalid */ -function checkTLSOptions(options: AnyOptions) { - if (!options) return null; - const check = (a: any, b: any) => assertRepelOptions(options, a, b); +export function checkTLSOptions(options: AnyOptions): void { + if (!options) return; + const check = (a: string, b: string) => { + if (Reflect.has(options, a) && Reflect.has(options, b)) { + throw new MongoParseError(`The '${a}' option cannot be used with '${b}'`); + } + }; check('tlsInsecure', 'tlsAllowInvalidCertificates'); check('tlsInsecure', 'tlsAllowInvalidHostnames'); check('tlsInsecure', 'tlsDisableCertificateRevocationCheck'); @@ -766,7 +756,7 @@ export function parseConnectionString( // NEW PARSER WORK... const HOSTS_REGEX = new RegExp( - '(?mongodb(?:\\+srv|)):\\/\\/(?:(?[^:]*)(?::(?[^@]*))?@)?(?[^\\/?]*)(?.*)' + String.raw`(?mongodb(?:\+srv|)):\/\/(?:(?[^:]*)(?::(?[^@]*))?@)?(?(?!:)[^\/?@]+)(?.*)` ); function parseURI(uri: string): { srv: boolean; url: URL; hosts: string[] } { @@ -785,26 +775,790 @@ function parseURI(uri: string): { srv: boolean; url: URL; hosts: string[] } { throw new MongoParseError('Invalid connection string, protocol and host(s) required'); } - const authString = username ? (password ? `${username}:${password}` : `${username}`) : ''; + decodeURIComponent(username ?? ''); + decodeURIComponent(password ?? ''); + + // characters not permitted in username nor password Set([':', '/', '?', '#', '[', ']', '@']) + const illegalCharacters = new RegExp(String.raw`[:/?#\[\]@]`, 'gi'); + if (username?.match(illegalCharacters)) { + throw new MongoParseError(`Username contains unescaped characters ${username}`); + } + if (!username || !password) { + const uriWithoutProtocol = uri.replace(`${protocol}://`, ''); + if (uriWithoutProtocol.startsWith('@') || uriWithoutProtocol.startsWith(':')) { + throw new MongoParseError('URI contained empty userinfo section'); + } + } + + if (password?.match(illegalCharacters)) { + throw new MongoParseError('Password contains unescaped characters'); + } + + let authString = ''; + if (typeof username === 'string') authString += username; + if (typeof password === 'string') authString += `:${password}`; + + const srv = protocol.includes('srv'); + const hostList = hosts.split(','); + const url = new URL(`${protocol.toLowerCase()}://${authString}@dummyHostname${rest}`); + + if (srv && hostList.length !== 1) { + throw new MongoParseError('mongodb+srv URI cannot have multiple service names'); + } + if (srv && hostList[0].includes(':')) { + throw new MongoParseError('mongodb+srv URI cannot have port number'); + } + return { - srv: protocol.includes('srv'), - url: new URL(`${protocol.toLowerCase()}://${authString}@dummyHostname${rest}`), + srv, + url, hosts: hosts.split(',') }; } +const TRUTHS = new Set(['true', 't', '1', 'y', 'yes']); +const FALSEHOODS = new Set(['false', 'f', '0', 'n', 'no', '-1']); +function getBoolean(name: string, value: unknown): boolean { + if (typeof value === 'boolean') return value; + const valueString = String(value).toLowerCase(); + if (TRUTHS.has(valueString)) return true; + if (FALSEHOODS.has(valueString)) return false; + throw new TypeError(`For ${name} Expected stringified boolean value, got: ${value}`); +} + +function getInt(name: string, value: unknown): number { + if (typeof value === 'number') return Math.trunc(value); + const parsedValue = Number.parseInt(String(value), 10); + if (!Number.isNaN(parsedValue)) return parsedValue; + throw new TypeError(`Expected ${name} to be stringified int value, got: ${value}`); +} + +function getUint(name: string, value: unknown): number { + const parsedValue = getInt(name, value); + if (parsedValue < 0) { + throw new TypeError(`${name} can only be a positive int value, got: ${value}`); + } + return parsedValue; +} + +function toRecord(value: string): Record { + const record = Object.create(null); + const keyValuePairs = value.split(','); + for (const keyValue of keyValuePairs) { + const [key, value] = keyValue.split(':'); + 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])); + } + has(k: string) { + return super.has(k.toLowerCase()); + } + get(k: string) { + return super.get(k.toLowerCase()); + } + set(k: string, v: any) { + return super.set(k.toLowerCase(), v); + } +} + export function parseOptions( uri: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars options: MongoClientOptions = {} ): Readonly { - try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { srv, url, hosts } = parseURI(uri); - const mongoOptions: MongoOptions = ({ srv, hosts } as unknown) as MongoOptions; - // TODO(NODE-2699): option parse logic - return Object.freeze(mongoOptions); - } catch { - return Object.freeze({} as MongoOptions); + const { url, hosts, srv } = parseURI(uri); + + // TODO(NODE-2704): Move back to test/tools/runner/config.js + options = { ...options }; + Reflect.deleteProperty(options, 'host'); + Reflect.deleteProperty(options, 'port'); + + 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 + }); + + const urlOptions = new CaseInsensitiveMap(); + for (const key of url.searchParams.keys()) { + const values = [...url.searchParams.getAll(key)]; + + if (values.includes('')) { + throw new MongoParseError('URI cannot contain options with no value'); + } + + if (urlOptions.has(key)) { + urlOptions.get(key)?.push(...values); + } else { + 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() + ]); + + for (const key of allKeys) { + const values = []; + if (urlOptions.has(key)) { + values.push(...urlOptions.get(key)); + } + if (objectOptions.has(key)) { + values.push(objectOptions.get(key)); + } + if (defaultOptions.has(key)) { + values.push(defaultOptions.get(key)); + } + allOptions.set(key, values); + } + + 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(); + checkTLSOptions(mongoOptions); + + return Object.freeze(mongoOptions) as Readonly; } + +function setOption( + mongoOptions: any, + key: string, + descriptor: OptionDescriptor, + values: unknown[] +) { + const { target, type, transform, deprecated } = descriptor; + const name = target ?? key; + + if (deprecated) { + console.warn(`${key} is a deprecated option`); + } + + switch (type) { + case 'boolean': + mongoOptions[name] = getBoolean(name, values[0]); + break; + case 'int': + mongoOptions[name] = getInt(name, values[0]); + break; + case 'uint': + mongoOptions[name] = getUint(name, values[0]); + break; + case 'string': + if (values[0] === undefined) { + break; + } + mongoOptions[name] = String(values[0]); + break; + case 'record': + if (!isRecord(values[0])) { + throw new TypeError(`${name} must be an object`); + } + mongoOptions[name] = values[0]; + break; + case 'any': + mongoOptions[name] = values[0]; + break; + default: { + if (!transform) { + throw new MongoParseError('Descriptors missing a type must define a transform'); + } + const transformValue = transform({ name, options: mongoOptions, values }); + mongoOptions[name] = transformValue; + break; + } + } +} + +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; + /** + * @param name - the original option name + * @param options - the options so far for resolution + * @param values - the possible values in precedence order + */ + transform?: (args: { name: string; options: MongoOptions; values: unknown[] }) => unknown; +} + +export const OPTIONS = { + appName: { + target: 'driverInfo', + transform({ options, values: [value] }): DriverInfo { + return { ...options.driverInfo, name: 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`); + } + return MongoCredentials.merge(options.credentials, { + username: value.username, + password: value.password + }); + } + }, + authMechanism: { + target: 'credentials', + transform({ options, values: [value] }): MongoCredentials { + 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}`); + } + let source = options.credentials.source; // some mechanisms have '$external' as the Auth Source + if ( + mechanism === AuthMechanism.MONGODB_PLAIN || + mechanism === AuthMechanism.MONGODB_GSSAPI || + mechanism === AuthMechanism.MONGODB_AWS || + mechanism === AuthMechanism.MONGODB_X509 + ) { + source = '$external'; + } + + let password: string | undefined = options.credentials.password; + if (mechanism === AuthMechanism.MONGODB_X509 && password === '') { + password = undefined; + } + return MongoCredentials.merge(options.credentials, { + mechanism, + source, + password + }); + } + }, + authMechanismProperties: { + target: 'credentials', + transform({ options, values: [value] }): MongoCredentials { + if (typeof value === 'string') { + value = toRecord(value); + } + if (!isRecord(value)) { + throw new TypeError('AuthMechanismProperties must be an object'); + } + return MongoCredentials.merge(options.credentials, { mechanismProperties: value }); + } + }, + authSource: { + target: 'credentials', + transform({ options, values: [value] }): MongoCredentials { + return MongoCredentials.merge(options.credentials, { source: String(value) }); + } + }, + autoEncryption: { + type: 'record' + }, + checkKeys: { + type: 'boolean' + }, + checkServerIdentity: { + target: 'checkServerIdentity', + transform({ + 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'); + 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', + transform({ values }) { + const compressionList = new Set(); + for (const compVal of values as string[]) { + for (const c of compVal.split(',')) { + if (['none', 'snappy', 'zlib'].includes(String(c))) { + compressionList.add(String(c)); + } else { + throw new TypeError(`${c} is not a valid compression mechanism`); + } + } + } + return [...compressionList]; + } + }, + connectTimeoutMS: { + default: 30000, + type: 'uint' + }, + dbName: { + default: 'test', + type: 'string' + }, + directConnection: { + default: false, + type: 'boolean' + }, + driverInfo: { + default: {}, + type: 'record' + }, + family: { + transform({ name, values: [value] }): 4 | 6 { + const transformValue = getInt(name, value); + if (transformValue === 4 || transformValue === 6) { + return transformValue; + } + throw new TypeError(`Option 'family' must be 4 or 6 got ${transformValue}.`); + } + }, + fieldsAsRaw: { + type: 'record' + }, + forceServerObjectId: { + default: false, + type: 'boolean' + }, + fsync: { + target: 'writeConcern', + transform({ name, options, values: [value] }): WriteConcern { + const wc = WriteConcern.fromOptions({ + ...options.writeConcern, + fsync: getBoolean(name, value) + }); + if (!wc) throw new TypeError(`Unable to make a writeConcern from fsync=${value}`); + return wc; + } + }, + heartbeatFrequencyMS: { + default: 10000, + type: 'uint' + }, + ignoreUndefined: { + type: 'boolean' + }, + j: { + target: 'writeConcern', + transform({ name, options, values: [value] }): WriteConcern { + console.warn('j is deprecated'); + const wc = WriteConcern.fromOptions({ + ...options.writeConcern, + journal: getBoolean(name, value) + }); + if (!wc) throw new TypeError(`Unable to make a writeConcern from journal=${value}`); + return wc; + } + }, + journal: { + target: 'writeConcern', + transform({ name, options, values: [value] }): WriteConcern { + const wc = WriteConcern.fromOptions({ + ...options.writeConcern, + journal: getBoolean(name, value) + }); + if (!wc) throw new TypeError(`Unable to make a writeConcern from journal=${value}`); + return wc; + } + }, + keepAlive: { + default: true, + type: 'boolean' + }, + keepAliveInitialDelay: { + default: 120000, + type: 'uint' + }, + localThresholdMS: { + default: 0, + type: 'uint' + }, + logger: { + default: new Logger('MongoClient'), + transform({ values: [value] }) { + if (value instanceof Logger) { + return value; + } + console.warn('Alternative loggers might not be supported'); + // TODO: make Logger an interface that others can implement, make usage consistent in driver + // DRIVERS-1204 + } + }, + loggerLevel: { + target: 'logger', + transform({ values: [value] }) { + return new Logger('MongoClient', { loggerLevel: value as LoggerLevel }); + } + }, + maxIdleTimeMS: { + default: 0, + type: 'uint' + }, + maxPoolSize: { + default: 100, + type: 'uint' + }, + maxStalenessSeconds: { + type: 'uint' + }, + minInternalBufferSize: { + type: 'uint' + }, + minPoolSize: { + default: 0, + type: 'uint' + }, + minHeartbeatFrequencyMS: { + type: 'uint' + }, + monitorCommands: { + default: true, + type: 'boolean' + }, + name: { + target: 'driverInfo', + transform({ values: [value], options }) { + return { ...options.driverInfo, name: String(value) }; + } + } as OptionDescriptor, + noDelay: { + 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( + `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: { + type: 'any' + }, + promoteBuffers: { + type: 'boolean' + }, + promoteLongs: { + type: 'boolean' + }, + promoteValues: { + type: 'boolean' + }, + raw: { + default: false, + type: 'boolean' + }, + readConcern: { + transform({ values: [value], options }) { + if (value instanceof ReadConcern || isRecord(value, ['level'] as const)) { + return ReadConcern.fromOptions({ ...options.readConcern, ...value } as any); + } + throw new MongoParseError(`ReadConcern must be an object, got ${JSON.stringify(value)}`); + } + }, + readConcernLevel: { + target: 'readConcern', + transform({ values: [level], options }) { + return ReadConcern.fromOptions({ + ...options.readConcern, + level: level as ReadConcernLevelId + }); + } + }, + readPreference: { + default: ReadPreference.primary, + transform({ values: [value], options }) { + if (value instanceof ReadPreference) { + return ReadPreference.fromOptions({ + readPreference: { ...options.readPreference, ...value }, + ...value + } as any); + } + if (isRecord(value, ['mode'] as const)) { + const rp = ReadPreference.fromOptions({ + readPreference: { ...options.readPreference, ...value }, + ...value + } as any); + if (rp) return rp; + else throw new MongoParseError(`Cannot make read preference from ${JSON.stringify(value)}`); + } + if (typeof value === 'string') { + const rpOpts = { + hedge: options.readPreference?.hedge, + maxStalenessSeconds: options.readPreference?.maxStalenessSeconds + }; + return new ReadPreference( + value as ReadPreferenceModeId, + options.readPreference?.tags, + rpOpts + ); + } + } + }, + readPreferenceTags: { + transform({ values }) { + const tags: TagSet = Object.create(null); + for (const tag of values) { + if (typeof tag === 'string') { + for (const [k, v] of Object.entries(toRecord(tag))) { + tags[k] = v; + } + } + if (isRecord(tag)) { + for (const [k, v] of Object.entries(tag)) { + tags[k] = v; + } + } + } + return tags; + } + }, + replicaSet: { + type: 'string' + }, + retryReads: { + default: true, + type: 'boolean' + }, + retryWrites: { + default: true, + type: 'boolean' + }, + serializeFunctions: { + type: 'boolean' + }, + serverSelectionTimeoutMS: { + default: 30000, + type: 'uint' + }, + servername: { + type: 'string' + }, + socketTimeoutMS: { + default: 0, + type: 'uint' + }, + ssl: { + target: 'tls', + deprecated: true, + type: 'boolean' + }, + sslCA: { + deprecated: true, + target: 'ca', + type: 'any' + }, + sslCRL: { + target: 'crl', + type: 'any' + }, + sslCert: { + deprecated: true, + target: 'cert', + type: 'any' + }, + sslKey: { + deprecated: true, + target: 'key', + type: 'any' + }, + sslPass: { + deprecated: true, + target: 'passphrase', + type: 'string' + }, + sslValidate: { + target: 'rejectUnauthorized', + type: 'boolean' + }, + tls: { + type: 'boolean' + }, + tlsAllowInvalidCertificates: { + type: 'boolean' + }, + tlsAllowInvalidHostnames: { + type: 'boolean' + }, + tlsCAFile: { + target: 'ca', + type: 'any' + }, + tlsCertificateFile: { + target: 'cert', + type: 'any' + }, + tlsCertificateKeyFile: { + target: 'key', + type: 'any' + }, + tlsCertificateKeyFilePassword: { + target: 'passphrase', + type: 'any' + }, + tlsInsecure: { + type: 'boolean' + }, + useRecoveryToken: { + 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 }) { + return WriteConcern.fromOptions({ ...options.writeConcern, w: value as W }); + } + }, + waitQueueTimeoutMS: { + default: 0, + type: 'uint' + }, + writeConcern: { + target: 'writeConcern', + transform({ values: [value], options }) { + if (isRecord(value)) { + return WriteConcern.fromOptions({ + ...options.writeConcern, + ...value + }); + } + throw new MongoParseError(`WriteConcern must be an object, got ${JSON.stringify(value)}`); + } + }, + wtimeout: { + target: 'writeConcern', + transform({ values: [value], options }) { + const wc = WriteConcern.fromOptions({ + ...options.writeConcern, + wtimeout: getUint('wtimeout', value) + }); + if (wc) return wc; + throw new MongoParseError(`Cannot make WriteConcern from wtimeout`); + } + }, + wtimeoutMS: { + target: 'writeConcern', + transform({ values: [value], options }) { + const wc = WriteConcern.fromOptions({ + ...options.writeConcern, + wtimeoutMS: getUint('wtimeoutMS', value) + }); + if (wc) return wc; + throw new MongoParseError(`Cannot make WriteConcern from wtimeout`); + } + }, + zlibCompressionLevel: { + default: 0, + type: 'int' + } +} as Record; diff --git a/src/db.ts b/src/db.ts index 1dba1730ea..776dd2e43b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -71,7 +71,6 @@ const legalOptionNames = [ 'pkFactory', 'serializeFunctions', 'raw', - 'bufferMaxEntries', 'authSource', 'ignoreUndefined', 'promoteLongs', diff --git a/src/index.ts b/src/index.ts index 0bf40949e8..c963c46506 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,7 +109,7 @@ export type { OperationTime, ResumeOptions } from './change_stream'; -export type { AuthMechanism } from './cmap/auth/defaultAuthProviders'; +export type { AuthMechanism, AuthMechanismId } from './cmap/auth/defaultAuthProviders'; export type { MongoCredentials, MongoCredentialsOptions } from './cmap/auth/mongo_credentials'; export type { WriteProtocolMessageType, @@ -138,13 +138,8 @@ export type { StreamDescription, StreamDescriptionOptions } from './cmap/stream_ export type { CommandOptions } from './cmap/wire_protocol/command'; export type { CompressorName, Compressor } from './cmap/wire_protocol/compression'; export type { GetMoreOptions } from './cmap/wire_protocol/get_more'; -export type { - InsertOptions as WireInsertOptions, - UpdateOptions as WireUpdateOptions, - RemoveOptions as WireRemoveOptions -} from './cmap/wire_protocol/index'; -export type { CollationOptions, WriteCommandOptions } from './cmap/wire_protocol/write_command'; export type { QueryOptions } from './cmap/wire_protocol/query'; +export type { CollationOptions, WriteCommandOptions } from './cmap/wire_protocol/write_command'; export type { CollectionPrivate, CollectionOptions } from './collection'; export type { AggregationCursorOptions } from './cursor/aggregation_cursor'; export type { @@ -179,9 +174,11 @@ export type { PkFactory, MongoURIOptions, LogLevel, + LogLevelId, Auth, DriverInfo, - MongoOptions + MongoOptions, + HostAddress } from './mongo_client'; export type { AddUserOptions } from './operations/add_user'; export type { @@ -236,12 +233,13 @@ export type { UpdateResult, UpdateOptions } from './operations/update'; export type { ValidateCollectionOptions } from './operations/validate_collection'; export type { ReadConcern, - ReadConcernLevel, ReadConcernLike, - ReadConcernLevelLike + ReadConcernLevel, + ReadConcernLevelId } from './read_concern'; export type { ReadPreferenceLike, + ReadPreferenceModeId, ReadPreferenceMode, ReadPreferenceOptions, ReadPreferenceLikeOptions, @@ -270,12 +268,12 @@ export type { Topology, TopologyPrivate, ServerSelectionRequest, - ServerAddress, TopologyOptions, ServerCapabilities, ConnectOptions, SelectServerOptions, - ServerSelectionCallback + ServerSelectionCallback, + ServerAddress } from './sdam/topology'; export type { TopologyDescription, TopologyDescriptionOptions } from './sdam/topology_description'; export type { diff --git a/src/mongo_client.ts b/src/mongo_client.ts index 14d45d64e7..c307b3c5c3 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -1,7 +1,7 @@ import { Db, DbOptions } from './db'; import { EventEmitter } from 'events'; import { ChangeStream, ChangeStreamOptions } from './change_stream'; -import { ReadPreference, ReadPreferenceMode } from './read_preference'; +import { ReadPreference, ReadPreferenceModeId } from './read_preference'; import { MongoError, AnyError } from './error'; import { WriteConcern, WriteConcernOptions } from './write_concern'; import { maybePromise, MongoDBNamespace, Callback, resolveOptions } from './utils'; @@ -9,11 +9,11 @@ import { deprecate } from 'util'; import { connect, validOptions } from './operations/connect'; import { PromiseProvider } from './promise_provider'; import { Logger } from './logger'; -import { ReadConcern, ReadConcernLevelLike, ReadConcernLike } from './read_concern'; +import { 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 { AuthMechanism } from './cmap/auth/defaultAuthProviders'; +import type { AuthMechanismId } from './cmap/auth/defaultAuthProviders'; import type { Topology } from './sdam/topology'; import type { ClientSession, ClientSessionOptions } from './sessions'; import type { OperationParent } from './operations/command'; @@ -24,12 +24,15 @@ import type { MongoCredentials } from './cmap/auth/mongo_credentials'; import { parseOptions } from './connection_string'; /** @public */ -export enum LogLevel { - 'error' = 'error', - 'warn' = 'warn', - 'info' = 'info', - 'debug' = 'debug' -} +export const LogLevel = { + error: 'error', + warn: 'warn', + info: 'info', + debug: 'debug' +} as const; + +/** @public */ +export type LogLevelId = typeof LogLevel[keyof typeof LogLevel]; /** @public */ export interface DriverInfo { @@ -41,9 +44,9 @@ export interface DriverInfo { /** @public */ export interface Auth { /** The username for auth */ - user?: string; + username?: string; /** The password for auth */ - pass?: string; + password?: string; } /** @public */ @@ -91,14 +94,12 @@ export interface MongoURIOptions extends Pick; /** SSL Certificate binary buffer. */ - sslCert?: Buffer; + sslCert?: string | Buffer | Array; /** SSL Key file binary buffer. */ - sslKey?: Buffer; + sslKey?: string | Buffer | Array; /** SSL Certificate pass phrase. */ sslPass?: string; /** SSL Certificate revocation list binary buffer. */ - sslCRL?: 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); /** TCP Connection no delay */ @@ -171,7 +175,7 @@ export interface MongoClientOptions /** Specify a read concern for the collection (only MongoDB 3.2 or higher supported) */ readConcern?: ReadConcernLike; /** The logging level */ - loggerLevel?: LogLevel; + loggerLevel?: LogLevelId; /** Custom logger object */ logger?: Logger; /** The auth settings for when connection to server. */ @@ -256,7 +260,7 @@ export class MongoClient extends EventEmitter implements OperationParent { * The consolidate, parsed, transformed and merged options. * @internal */ - options: MongoOptions; + options; // debugging originalUri; @@ -552,9 +556,15 @@ export class MongoClient extends EventEmitter implements OperationParent { }, '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 - * @internal + * @public */ export interface MongoOptions extends Required, @@ -571,7 +581,7 @@ export interface MongoOptions | 'directConnection' | 'driverInfo' | 'forceServerObjectId' - | 'gssapiServiceName' + | 'minHeartbeatFrequencyMS' | 'heartbeatFrequencyMS' | 'keepAlive' | 'keepAliveInitialDelay' @@ -590,7 +600,6 @@ export interface MongoOptions | 'retryReads' | 'retryWrites' | 'serverSelectionTimeoutMS' - | 'serverSelectionTryOnce' | 'socketTimeoutMS' | 'tlsAllowInvalidCertificates' | 'tlsAllowInvalidHostnames' @@ -599,7 +608,7 @@ export interface MongoOptions | 'zlibCompressionLevel' > > { - hosts: { host: string; port: number }[]; + hosts: HostAddress[]; srv: boolean; credentials: MongoCredentials; readPreference: ReadPreference; diff --git a/src/operations/connect.ts b/src/operations/connect.ts index 285c9670ae..cf19c0f990 100644 --- a/src/operations/connect.ts +++ b/src/operations/connect.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import { Logger } from '../logger'; import { ReadPreference } from '../read_preference'; import { MongoError, AnyError } from '../error'; -import { Topology, TopologyOptions, ServerAddress } from '../sdam/topology'; -import { parseConnectionString } from '../connection_string'; +import { ServerAddress, Topology, TopologyOptions } from '../sdam/topology'; +import { AUTH_MECHANISMS, parseConnectionString } from '../connection_string'; import { ReadConcern } from '../read_concern'; import { emitDeprecationWarning, Callback } from '../utils'; import { CMAP_EVENT_NAMES } from '../cmap/events'; @@ -12,20 +12,9 @@ import * as BSON from '../bson'; import type { Document } from '../bson'; import type { MongoClient } from '../mongo_client'; import { ConnectionOptions, Connection } from '../cmap/connection'; -import type { AuthMechanism } from '../cmap/auth/defaultAuthProviders'; +import { AuthMechanism, AuthMechanismId } from '../cmap/auth/defaultAuthProviders'; import { Server } from '../sdam/server'; -const VALID_AUTH_MECHANISMS = new Set([ - 'DEFAULT', - 'PLAIN', - 'GSSAPI', - 'MONGODB-CR', - 'MONGODB-X509', - 'MONGODB-AWS', - 'SCRAM-SHA-1', - 'SCRAM-SHA-256' -]); - const validOptionNames = [ 'poolSize', 'ssl', @@ -58,7 +47,6 @@ const validOptionNames = [ 'serializeFunctions', 'ignoreUndefined', 'raw', - 'bufferMaxEntries', 'readPreference', 'pkFactory', 'promiseLibrary', @@ -75,6 +63,8 @@ const validOptionNames = [ 'appname', 'auth', 'user', + 'username', + 'host', 'password', 'authMechanism', 'compression', @@ -447,7 +437,7 @@ export interface GenerateCredentialsOptions { authSource: string; authdb: string; dbName: string; - authMechanism: AuthMechanism; + authMechanism: AuthMechanismId; authMechanismProperties: Document; } @@ -464,16 +454,16 @@ function generateCredentials( const source = options.authSource || options.authdb || options.dbName; // authMechanism - const authMechanismRaw = options.authMechanism || 'DEFAULT'; - const authMechanism = authMechanismRaw.toUpperCase(); + const authMechanismRaw = options.authMechanism || AuthMechanism.MONGODB_DEFAULT; + const mechanism = authMechanismRaw.toUpperCase() as AuthMechanismId; const mechanismProperties = options.authMechanismProperties; - if (!VALID_AUTH_MECHANISMS.has(authMechanism)) { - throw new MongoError(`authentication mechanism ${authMechanism} not supported`); + if (!AUTH_MECHANISMS.has(mechanism)) { + throw new MongoError(`authentication mechanism ${mechanism} not supported`); } return new MongoCredentials({ - mechanism: authMechanism as AuthMechanism, + mechanism, mechanismProperties, source, username, diff --git a/src/read_concern.ts b/src/read_concern.ts index 4db8169772..ad02d4960d 100644 --- a/src/read_concern.ts +++ b/src/read_concern.ts @@ -1,19 +1,19 @@ import type { Document } from './bson'; /** @public */ -export enum ReadConcernLevel { - local = 'local', - majority = 'majority', - linearizable = 'linearizable', - available = 'available', - snapshot = 'snapshot' -} +export const ReadConcernLevel = { + local: 'local', + majority: 'majority', + linearizable: 'linearizable', + available: 'available', + snapshot: 'snapshot' +} as const; /** @public */ -export type ReadConcernLevelLike = ReadConcernLevel | keyof typeof ReadConcernLevel; +export type ReadConcernLevelId = keyof typeof ReadConcernLevel; /** @public */ -export type ReadConcernLike = ReadConcern | { level: ReadConcernLevelLike } | ReadConcernLevelLike; +export type ReadConcernLike = ReadConcern | { level: ReadConcernLevelId } | ReadConcernLevelId; /** * The MongoDB ReadConcern, which allows for control of the consistency and isolation properties @@ -23,17 +23,17 @@ export type ReadConcernLike = ReadConcern | { level: ReadConcernLevelLike } | Re * @see https://docs.mongodb.com/manual/reference/read-concern/index.html */ export class ReadConcern { - level: ReadConcernLevel | string; + level: ReadConcernLevelId | string; /** Constructs a ReadConcern from the read concern level.*/ - constructor(level: ReadConcernLevelLike) { + constructor(level: ReadConcernLevelId) { /** * A spec test exists that allows level to be any string. * "invalid readConcern with out stage" * @see ./test/spec/crud/v2/aggregate-out-readConcern.json * @see https://github.com/mongodb/specifications/blob/master/source/read-write-concern/read-write-concern.rst#unknown-levels-and-additional-options-for-string-based-readconcerns */ - this.level = ReadConcernLevel[level] || level; + this.level = ReadConcernLevel[level] ?? level; } /** @@ -43,7 +43,7 @@ export class ReadConcern { */ static fromOptions(options?: { readConcern?: ReadConcernLike; - level?: ReadConcernLevelLike; + level?: ReadConcernLevelId; }): ReadConcern | undefined { if (options == null) { return; @@ -65,19 +65,19 @@ export class ReadConcern { } } - static get MAJORITY(): string { + static get MAJORITY(): 'majority' { return ReadConcernLevel.majority; } - static get AVAILABLE(): string { + static get AVAILABLE(): 'available' { return ReadConcernLevel.available; } - static get LINEARIZABLE(): string { + static get LINEARIZABLE(): 'linearizable' { return ReadConcernLevel.linearizable; } - static get SNAPSHOT(): string { + static get SNAPSHOT(): 'snapshot' { return ReadConcernLevel.snapshot; } diff --git a/src/read_preference.ts b/src/read_preference.ts index 7d885f2856..ca793614f6 100644 --- a/src/read_preference.ts +++ b/src/read_preference.ts @@ -3,19 +3,19 @@ import type { Document } from './bson'; import type { ClientSession } from './sessions'; /** @public */ -export type ReadPreferenceLike = - | ReadPreference - | ReadPreferenceMode - | keyof typeof ReadPreferenceMode; +export type ReadPreferenceLike = ReadPreference | ReadPreferenceModeId; /** @public */ -export enum ReadPreferenceMode { - primary = 'primary', - primaryPreferred = 'primaryPreferred', - secondary = 'secondary', - secondaryPreferred = 'secondaryPreferred', - nearest = 'nearest' -} +export const ReadPreferenceMode = { + primary: 'primary', + primaryPreferred: 'primaryPreferred', + secondary: 'secondary', + secondaryPreferred: 'secondaryPreferred', + nearest: 'nearest' +} as const; + +/** @public */ +export type ReadPreferenceModeId = keyof typeof ReadPreferenceMode; /** @public */ export interface HedgeOptions { @@ -36,17 +36,16 @@ export interface ReadPreferenceLikeOptions extends ReadPreferenceOptions { readPreference?: | ReadPreferenceLike | { - mode?: ReadPreferenceMode; - preference?: ReadPreferenceMode; - tags: TagSet[]; - maxStalenessSeconds: number; + mode?: ReadPreferenceModeId; + preference?: ReadPreferenceModeId; + tags?: TagSet[]; + maxStalenessSeconds?: number; }; } /** @public */ -export interface ReadPreferenceFromOptions { +export interface ReadPreferenceFromOptions extends ReadPreferenceLikeOptions { session?: ClientSession; - readPreference?: ReadPreferenceLikeOptions['readPreference']; readPreferenceTags?: TagSet[]; hedge?: HedgeOptions; } @@ -59,7 +58,7 @@ export interface ReadPreferenceFromOptions { * @see https://docs.mongodb.com/manual/core/read-preference/ */ export class ReadPreference { - mode: ReadPreferenceMode; + mode: ReadPreferenceModeId; tags?: TagSet[]; hedge?: HedgeOptions; maxStalenessSeconds?: number; @@ -82,9 +81,9 @@ export class ReadPreference { * @param tags - A tag set used to target reads to members with the specified tag(s). tagSet is not available if using read preference mode primary. * @param options - Additional read preference options */ - constructor(mode: ReadPreferenceMode, tags?: TagSet[], options?: ReadPreferenceOptions) { + constructor(mode: ReadPreferenceModeId, tags?: TagSet[], options?: ReadPreferenceOptions) { if (!ReadPreference.isValid(mode)) { - throw new TypeError(`Invalid read preference mode ${mode}`); + throw new TypeError(`Invalid read preference mode ${JSON.stringify(mode)}`); } if (options === undefined && typeof tags === 'object' && !Array.isArray(tags)) { options = tags; @@ -96,6 +95,8 @@ export class ReadPreference { this.mode = mode; this.tags = tags; this.hedge = options?.hedge; + this.maxStalenessSeconds = undefined; + this.minWireVersion = undefined; options = options || {}; if (options.maxStalenessSeconds != null) { @@ -126,12 +127,12 @@ export class ReadPreference { } // Support the deprecated `preference` property introduced in the porcelain layer - get preference(): ReadPreferenceMode { + get preference(): ReadPreferenceModeId { return this.mode; } static fromString(mode: string): ReadPreference { - return new ReadPreference(mode as ReadPreferenceMode); + return new ReadPreference(mode as ReadPreferenceModeId); } /** @@ -150,11 +151,11 @@ export class ReadPreference { } if (typeof readPreference === 'string') { - return new ReadPreference(readPreference as ReadPreferenceMode, readPreferenceTags); + return new ReadPreference(readPreference as ReadPreferenceModeId, readPreferenceTags); } else if (!(readPreference instanceof ReadPreference) && typeof readPreference === 'object') { const mode = readPreference.mode || readPreference.preference; if (mode && typeof mode === 'string') { - return new ReadPreference(mode as ReadPreferenceMode, readPreference.tags, { + return new ReadPreference(mode as ReadPreferenceModeId, readPreference.tags, { maxStalenessSeconds: readPreference.maxStalenessSeconds, hedge: options.hedge }); @@ -172,11 +173,11 @@ export class ReadPreference { const r = options.readPreference; if (typeof r === 'string') { - options.readPreference = new ReadPreference(r as ReadPreferenceMode); + options.readPreference = new ReadPreference(r as ReadPreferenceModeId); } else if (r && !(r instanceof ReadPreference) && typeof r === 'object') { const mode = r.mode || r.preference; if (mode && typeof mode === 'string') { - options.readPreference = new ReadPreference(mode as ReadPreferenceMode, r.tags, { + options.readPreference = new ReadPreference(mode as ReadPreferenceModeId, r.tags, { maxStalenessSeconds: r.maxStalenessSeconds }); } @@ -202,7 +203,7 @@ export class ReadPreference { null ]); - return VALID_MODES.has(mode as ReadPreferenceMode); + return VALID_MODES.has(mode as ReadPreferenceModeId); } /** @@ -220,7 +221,7 @@ export class ReadPreference { * @see https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/#op-query */ slaveOk(): boolean { - const NEEDS_SLAVEOK = new Set([ + const NEEDS_SLAVEOK = new Set([ ReadPreference.PRIMARY_PREFERRED, ReadPreference.SECONDARY, ReadPreference.SECONDARY_PREFERRED, diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index d981f64a8b..060cbc178d 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -235,7 +235,7 @@ export class Topology extends EventEmitter { result.set(address, new ServerDescription(address)); return result; }, - new Map() + new Map() ); this[kWaitQueue] = new Denque(); diff --git a/src/sessions.ts b/src/sessions.ts index 6ecbbd273b..bab147f488 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -537,11 +537,8 @@ function endTransaction(session: ClientSession, commandName: string, callback: C callback(e, r); } - if ( + if (session.transaction.recoveryToken && supportsRecoveryToken(session)) { // Assumption here that commandName is "commitTransaction" or "abortTransaction" - session.transaction.recoveryToken && - supportsRecoveryToken(session) - ) { command.recoveryToken = session.transaction.recoveryToken; } diff --git a/src/utils.ts b/src/utils.ts index 94c6416b88..c80042cc46 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1129,3 +1129,32 @@ export function resolveOptions( return result; } + +export function isSuperset(set: Set | any[], subset: Set | any[]): boolean { + set = Array.isArray(set) ? new Set(set) : set; + subset = Array.isArray(subset) ? new Set(subset) : subset; + for (const elem of subset) { + if (!set.has(elem)) { + return false; + } + } + return true; +} + +export function isRecord( + value: unknown, + requiredKeys: T +): value is Record; +export function isRecord(value: unknown): value is Record; +export function isRecord( + value: unknown, + requiredKeys: string[] | undefined = undefined +): value is Record { + const isRecord = !!value && typeof value === 'object' && !Array.isArray(value); + if (isRecord && requiredKeys) { + const keys = Object.keys(value as Record); + return isSuperset(keys, requiredKeys); + } + + return isRecord; +} diff --git a/src/write_concern.ts b/src/write_concern.ts index d74028990b..a97b914f8e 100644 --- a/src/write_concern.ts +++ b/src/write_concern.ts @@ -19,8 +19,6 @@ export interface WriteConcernOptions { writeConcern?: WriteConcernOptions | WriteConcern | W; } -export const writeConcernKeys = ['w', 'j', 'wtimeout', 'fsync']; - /** * A MongoDB WriteConcern, which describes the level of acknowledgement * requested from MongoDB for write operations. @@ -29,28 +27,29 @@ export const writeConcernKeys = ['w', 'j', 'wtimeout', 'fsync']; * @see https://docs.mongodb.com/manual/reference/write-concern/ */ export class WriteConcern { - /** The write concern */ + /** request acknowledgment that the write operation has propagated to a specified number of mongod instances or to mongod instances with specified tags. */ w?: W; - /** The write concern timeout */ + /** specify a time limit to prevent write operations from blocking indefinitely */ wtimeout?: number; - /** The journal write concern */ + /** request acknowledgment that the write operation has been written to the on-disk journal */ j?: boolean; - /** The file sync write concern */ + /** equivalent to the j option */ fsync?: boolean | 1; - /** Constructs a WriteConcern from the write concern properties. */ - constructor( - /** The write concern */ - w?: W, - /** The write concern timeout */ - wtimeout?: number, - /** The journal write concern */ - j?: boolean, - /** The file sync write concern */ - fsync?: boolean | 1 - ) { + /** + * Constructs a WriteConcern from the write concern properties. + * @param w - request acknowledgment that the write operation has propagated to a specified number of mongod instances or to mongod instances with specified tags. + * @param wtimeout - specify a time limit to prevent write operations from blocking indefinitely + * @param j - request acknowledgment that the write operation has been written to the on-disk journal + * @param fsync - equivalent to the j option + */ + constructor(w?: W, wtimeout?: number, j?: boolean, fsync?: boolean | 1) { if (w != null) { - this.w = w; + if (!Number.isNaN(Number(w))) { + this.w = Number(w); + } else { + this.w = w; + } } if (wtimeout != null) { this.wtimeout = wtimeout; diff --git a/test/functional/change_stream.test.js b/test/functional/change_stream.test.js index e326127c4d..cb77a6cb95 100644 --- a/test/functional/change_stream.test.js +++ b/test/functional/change_stream.test.js @@ -1585,7 +1585,6 @@ describe('Change Streams', function () { const fileContents = fs.readFileSync(filename, 'utf8'); const parsedFileContents = JSON.parse(fileContents); expect(parsedFileContents).to.have.nested.property('fullDocument.a', 1); - done(); }); }); diff --git a/test/functional/connection.test.js b/test/functional/connection.test.js index 87cf90a390..5747f461b8 100644 --- a/test/functional/connection.test.js +++ b/test/functional/connection.test.js @@ -200,8 +200,8 @@ describe('Connection', function () { test: function (done) { var configuration = this.configuration; - var user = 'testConnectGoodAuthAsOption', - password = 'password'; + const username = 'testConnectGoodAuthAsOption'; + const password = 'password'; // First add a user. const setupClient = configuration.newClient(); @@ -209,14 +209,14 @@ describe('Connection', function () { expect(err).to.not.exist; var db = client.db(configuration.db); - db.addUser(user, password, function (err) { + db.addUser(username, password, function (err) { expect(err).to.not.exist; client.close(restOfTest); }); }); function restOfTest() { - var opts = { auth: { user: user, password: password } }; + var opts = { auth: { username, password } }; const testClient = configuration.newClient( configuration.url('baduser', 'badpassword'), @@ -249,17 +249,9 @@ describe('Connection', function () { it('test connect bad url', { metadata: { requires: { topology: 'single' } }, - test: function (done) { + test: function () { const configuration = this.configuration; - const client = configuration.newClient('mangodb://localhost:27017/test?safe=false'); - - test.throws(function () { - client.connect(function () { - test.ok(false, 'Bad URL!'); - }); - }); - - done(); + expect(() => configuration.newClient('mangodb://localhost:27017/test?safe=false')).to.throw(); } }); diff --git a/test/functional/db.test.js b/test/functional/db.test.js index 2279f45ece..6f4f9ee497 100644 --- a/test/functional/db.test.js +++ b/test/functional/db.test.js @@ -40,9 +40,7 @@ describe('Db', function () { test: function (done) { var configuration = this.configuration; - var client = configuration.newClient(configuration.writeConcernMax(), { - maxPoolSize: 1 - }); + var client = configuration.newClient(configuration.writeConcernMax(), { maxPoolSize: 1 }); client.connect(function (err, client) { expect(err).to.not.exist; var db = client.db(configuration.db); @@ -76,9 +74,7 @@ describe('Db', function () { test: function (done) { let configuration = this.configuration; - let client = configuration.newClient(configuration.writeConcernMax(), { - maxPoolSize: 1 - }); + let client = configuration.newClient(configuration.writeConcernMax(), { maxPoolSize: 1 }); client.connect(function (err, client) { let callbackCalled = 0; diff --git a/test/functional/mongo_client.test.js b/test/functional/mongo_client.test.js index 58f6448183..4ce2dce695 100644 --- a/test/functional/mongo_client.test.js +++ b/test/functional/mongo_client.test.js @@ -69,31 +69,10 @@ describe('MongoClient', function () { metadata: { requires: { topology: ['single', 'replicaset', 'sharded', 'ssl', 'heap', 'wiredtiger'] } }, - - test: function (done) { - var configuration = this.configuration; - const client = configuration.newClient('user:password@localhost:27017/test'); - - client.connect(function (err) { - expect(err).to.exist.and.to.have.property('message', 'Invalid connection string'); - done(); - }); - } - }); - - it('Should fail due to wrong uri user:password@localhost, with new url parser', { - metadata: { - requires: { topology: ['single', 'replicaset', 'sharded', 'ssl', 'heap', 'wiredtiger'] } - }, - - test: function (done) { - var configuration = this.configuration; - const client = configuration.newClient('user:password@localhost:27017/test'); - - client.connect(function (err) { - test.equal(err.message, 'Invalid connection string'); - done(); - }); + test() { + expect(() => this.configuration.newClient('user:password@localhost:27017/test')).to.throw( + 'Invalid connection string user:password@localhost:27017/test' + ); } }); diff --git a/test/functional/readpreference.test.js b/test/functional/readpreference.test.js index b7b944ebdf..6d26065308 100644 --- a/test/functional/readpreference.test.js +++ b/test/functional/readpreference.test.js @@ -453,7 +453,7 @@ describe('ReadPreference', function () { client.connect((err, client) => { const db = client.db(configuration.db); expect(db.collection.bind(db, 'test', { readPreference: 'invalid' })).to.throw( - 'Invalid read preference mode invalid' + 'Invalid read preference mode "invalid"' ); client.close(done); diff --git a/test/functional/saslprep.test.js b/test/functional/saslprep.test.js index 6293cbc63b..d22d6b58f8 100644 --- a/test/functional/saslprep.test.js +++ b/test/functional/saslprep.test.js @@ -79,7 +79,7 @@ describe('SASLPrep', function () { }, test: function () { const options = { - user: username, + username: username, password: password, authSource: 'admin', authMechanism: 'SCRAM-SHA-256' diff --git a/test/functional/scram_sha_256.test.js b/test/functional/scram_sha_256.test.js index 2776619d78..aa95e1d655 100644 --- a/test/functional/scram_sha_256.test.js +++ b/test/functional/scram_sha_256.test.js @@ -80,7 +80,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const options = { auth: { - user: user.username, + username: user.username, password: user.password }, authMechanism: mechanism, @@ -119,7 +119,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const options = { auth: { - user: user.username, + username: user.username, password: user.password }, authSource: this.configuration.db @@ -154,7 +154,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const options = { auth: { - user: userMap.both.username, + username: userMap.both.username, password: userMap.both.password }, authSource: this.configuration.db @@ -173,7 +173,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const options = { auth: { - user: userMap.both.username, + username: userMap.both.username, password: userMap.both.password }, authSource: this.configuration.db @@ -205,7 +205,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const options = { auth: { - user: userMap.sha256.username, + username: userMap.sha256.username, password: userMap.sha256.password }, authSource: this.configuration.db, @@ -229,7 +229,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const noUsernameOptions = { auth: { - user: 'roth', + username: 'roth', password: 'pencil' }, authSource: 'admin' @@ -237,7 +237,7 @@ describe('SCRAM-SHA-256 auth', function () { const badPasswordOptions = { auth: { - user: 'both', + username: 'both', password: 'pencil' }, authSource: 'admin' @@ -259,7 +259,7 @@ describe('SCRAM-SHA-256 auth', function () { test: function () { const options = { auth: { - user: userMap.both.username, + username: userMap.both.username, password: userMap.both.password }, authSource: this.configuration.db diff --git a/test/functional/spec-runner/index.js b/test/functional/spec-runner/index.js index 37612743fb..f510e613e2 100644 --- a/test/functional/spec-runner/index.js +++ b/test/functional/spec-runner/index.js @@ -4,6 +4,7 @@ const fs = require('fs'); const chai = require('chai'); const expect = chai.expect; const { EJSON } = require('bson'); +const { isRecord } = require('../../../src/utils'); const TestRunnerContext = require('./context').TestRunnerContext; const resolveConnectionString = require('./utils').resolveConnectionString; const hasOwnProperty = Object.prototype.hasOwnProperty; @@ -29,10 +30,6 @@ function escape(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function isPlainObject(value) { - return value !== null && typeof value === 'object' && Array.isArray(value) === false; -} - function translateClientOptions(options) { Object.keys(options).forEach(key => { if (key === 'readConcernLevel') { @@ -427,7 +424,7 @@ function normalizeCommandShapes(commands) { } function extractCrudResult(result, operation) { - if (Array.isArray(result) || !isPlainObject(result)) { + if (Array.isArray(result) || !isRecord(result)) { return result; } diff --git a/test/functional/uri.test.js b/test/functional/uri.test.js index d0cf7d99a8..ab19d8a904 100644 --- a/test/functional/uri.test.js +++ b/test/functional/uri.test.js @@ -50,9 +50,7 @@ describe('URI', function () { return done(); } - const client = this.configuration.newClient( - 'mongodb://%2Ftmp%2Fmongodb-27017.sock?safe=false' - ); + const client = this.configuration.newClient('mongodb://%2Ftmp%2Fmongodb-27017.sock'); client.connect(function (err, client) { expect(err).to.not.exist; diff --git a/test/functional/uri_options_spec.test.js b/test/functional/uri_options_spec.test.js index e5ac40991a..e9e8f135fc 100644 --- a/test/functional/uri_options_spec.test.js +++ b/test/functional/uri_options_spec.test.js @@ -4,8 +4,7 @@ const chai = require('chai'); const expect = chai.expect; chai.use(require('chai-subset')); -const { parseConnectionString: parse } = require('../../src/connection_string'); -const { MongoParseError } = require('../../src/error'); +const { parseOptions } = require('../../src/connection_string'); const { loadSpecTests } = require('../spec'); describe('URI Options (spec)', function () { @@ -14,23 +13,18 @@ describe('URI Options (spec)', function () { suite.tests.forEach(test => { const itFn = test.warning ? it.skip : it; - itFn(test.description, { - metadata: { requires: { topology: 'single' } }, - test: function (done) { - parse(test.uri, {}, (err, result) => { - if (test.valid === true) { - expect(err).to.not.exist; - if (test.options.compressors != null) { - result.options.compressors = result.options.compression.compressors; - result.options.zlibCompressionLevel = - result.options.compression.zlibCompressionLevel; - } - expect(result.options).to.containSubset(test.options); - } else { - expect(err).to.be.an.instanceof(MongoParseError); + itFn(`${test.description}`, function () { + try { + const options = parseOptions(test.uri, {}); + if (test.valid === true) { + if (test.options.compressors != null) { + options.compressors = options.compression.compressors; + options.zlibCompressionLevel = options.compression.zlibCompressionLevel; } - done(); - }); + expect(options).to.containSubset(test.options); + } + } catch (err) { + expect(err).to.be.an.instanceof(Error); } }); }); diff --git a/test/tools/runner/config.js b/test/tools/runner/config.js index 0a8307bd89..1a02980a6a 100644 --- a/test/tools/runner/config.js +++ b/test/tools/runner/config.js @@ -123,6 +123,9 @@ class NativeConfiguration { urlOptions.auth = auth; } + // TODO(NODE-2704): Uncomment this, unix socket related issues + // Reflect.deleteProperty(serverOptions, 'host'); + // Reflect.deleteProperty(serverOptions, 'port'); const connectionString = url.format(urlOptions); return new MongoClient(connectionString, serverOptions); } diff --git a/test/unit/core/connection_string.test.js b/test/unit/core/connection_string.test.js index ee8c054c07..41ee765ff2 100644 --- a/test/unit/core/connection_string.test.js +++ b/test/unit/core/connection_string.test.js @@ -5,6 +5,7 @@ const punycode = require('punycode'); const { MongoParseError } = require('../../../src/error'); const { loadSpecTests } = require('../../spec'); const chai = require('chai'); +const { parseOptions } = require('../../../src/connection_string'); const expect = chai.expect; chai.use(require('chai-subset')); @@ -213,8 +214,57 @@ describe('Connection String', function () { }); 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 () { + if (skipTests.includes(test.description)) { + return this.skip(); + } + + const message = `"${test.uri}"`; + + const valid = test.valid; + if (valid) { + const options = parseOptions(test.uri); + expect(options, message).to.be.ok; + + if (test.hosts) { + for (const [index, { host, port }] of test.hosts.entries()) { + expect(options.hosts[index].host, message).to.equal(host); + if (typeof port === 'number') expect(options.hosts[index].port).to.equal(port); + } + } + + if (test.auth) { + if (test.auth.db != null) { + expect(options.credentials.source, message).to.equal(test.auth.db); + } + + if (test.auth.username != null) { + expect(options.credentials.username, message).to.equal(test.auth.username); + } + + if (test.auth.password != null) { + expect(options.credentials.password, message).to.equal(test.auth.password); + } + } + + // TODO + // if (test.options) { + // expect(options, message).to.deep.include(test.options); + // } + } else { + expect(() => parseOptions(test.uri), message).to.throw(); + } + }); + } + }); + } + suites.forEach(suite => { describe(suite.name, function () { suite.tests.forEach(test => { diff --git a/test/unit/mongo_client_options.test.js b/test/unit/mongo_client_options.test.js new file mode 100644 index 0000000000..74b46fd86a --- /dev/null +++ b/test/unit/mongo_client_options.test.js @@ -0,0 +1,312 @@ +'use strict'; + +const { expect } = require('chai'); +const { parseOptions } = require('../../src/connection_string'); +const { ReadConcern } = require('../../src/read_concern'); +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'); + +describe('MongoOptions', function () { + it('parseOptions should always return frozen object', function () { + expect(parseOptions('mongodb://localhost:27017')).to.be.frozen; + }); + + it('test simple', function () { + const options = parseOptions('mongodb://localhost:27017/test?directConnection=true', { + directConnection: false + }); + expect(options.directConnection).to.be.true; + expect(options.hosts).has.length(1); + expect(options.dbName).to.equal('test'); + expect(options.prototype).to.not.exist; + }); + + it('tls renames', function () { + const options = parseOptions('mongodb://localhost:27017/?ssl=true', { + tlsCertificateKeyFile: [{ pem: 'pem' }, { pem: 'pem2', passphrase: 'passphrase' }], + tlsCertificateFile: 'tlsCertificateFile', + tlsCAFile: 'tlsCAFile', + sslCRL: 'sslCRL', + tlsCertificateKeyFilePassword: 'tlsCertificateKeyFilePassword', + sslValidate: false + }); + + /* + * If set TLS enabled, equivalent to setting the ssl option. + * + * ### Additional options: + * + * | nodejs option | MongoDB equivalent | type | + * |:---------------------|----------------------------------------------------|:---------------------------------------| + * | `ca` | sslCA, tlsCAFile | `string \| Buffer \| Buffer[]` | + * | `crl` | sslCRL | `string \| Buffer \| Buffer[]` | + * | `cert` | sslCert, tlsCertificateFile | `string \| Buffer \| Buffer[]` | + * | `key` | sslKey, tlsCertificateKeyFile | `string \| Buffer \| KeyObject[]` | + * | `passphrase` | sslPass, tlsCertificateKeyFilePassword | `string` | + * | `rejectUnauthorized` | sslValidate | `boolean` | + * + */ + expect(options).to.not.have.property('tlsCertificateKeyFile'); + 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('key'); + expect(options.key).has.length(2); + expect(options).has.property('passphrase', 'tlsCertificateKeyFilePassword'); + expect(options).has.property('rejectUnauthorized', false); + expect(options).has.property('tls', true); + }); + + const ALL_OPTIONS = { + appName: 'cats', + auth: { username: 'username', password: 'password' }, + authMechanism: 'SCRAM-SHA-1', + authMechanismProperties: { SERVICE_NAME: 'service name here' }, + authSource: 'refer to dbName', + autoEncryption: { bypassAutoEncryption: true }, + checkKeys: true, + checkServerIdentity: false, + compression: 'zlib', + compressors: 'snappy', // TODO + connectTimeoutMS: 123, + directConnection: true, + dbName: 'test', + driverInfo: { name: 'MyDriver', platform: 'moonOS' }, + family: 6, + fieldsAsRaw: { rawField: true }, + forceServerObjectId: true, + fsync: true, + heartbeatFrequencyMS: 3, + ignoreUndefined: false, + j: true, + journal: false, + keepAlive: true, + keepAliveInitialDelay: 3, + localThresholdMS: 3, + logger: new Logger('Testing!'), + loggerLevel: 'info', + maxIdleTimeMS: 3, + maxPoolSize: 2, + maxStalenessSeconds: 3, + minInternalBufferSize: 0, + minPoolSize: 1, + monitorCommands: true, + noDelay: true, + numberOfRetries: 3, + pkFactory: { + createPk() { + return 'very unique'; + } + }, + promiseLibrary: global.Promise, + promoteBuffers: true, + promoteLongs: false, + promoteValues: false, + raw: true, + readConcern: new ReadConcern(ReadConcern.AVAILABLE), + readConcernLevel: ReadConcern.LINEARIZABLE, + readPreference: ReadPreference.primary, + readPreferenceTags: [{ loc: 'ny' }], + replicaSet: 'phil', + retryReads: false, + 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), + wtimeout: 5, + wtimeoutMS: 6, + zlibCompressionLevel: 2 + }; + + it('All options', function () { + const options = parseOptions('mongodb://localhost:27017/', ALL_OPTIONS); + + // Check consolidated options + expect(options).has.property('writeConcern'); + expect(options.writeConcern).has.property('w', 2); + expect(options.writeConcern).has.property('j', true); + }); + + const allURIOptions = + 'mongodb://myName@localhost:27017/test?' + + [ + 'appname=myBestApp', + 'authMechanism=scram-sha-1', + 'authMechanismProperties=opt1:val1', + 'authSource=authDb', + 'compressors=zlib,snappy', + 'connectTimeoutMS=2', + 'directConnection=true', + 'heartbeatFrequencyMS=2', + 'journal=true', + 'localThresholdMS=2', + 'maxIdleTimeMS=2', + 'maxPoolSize=4', + 'maxStalenessSeconds=2', + 'minPoolSize=2', + 'readConcernLevel=local', + 'readPreference=nearest', + 'readPreferenceTags=dc:ny,rack:1', + 'replicaSet=phil', + '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', + 'zlibCompressionLevel=2' + ].join('&'); + + it('All URI options', function () { + const options = parseOptions(allURIOptions); + expect(options).has.property('zlibCompressionLevel', 2); + + expect(options).has.property('writeConcern'); + expect(options.writeConcern).has.property('w', 'majority'); + expect(options.writeConcern).has.property('wtimeout', 2); + }); + + it('srv', function () { + const options = parseOptions('mongodb+srv://server.example.com/'); + expect(options).has.property('srv', true); + }); + + it('supports ReadPreference option in url', function () { + const options = parseOptions('mongodb://localhost/?readPreference=nearest'); + expect(options.readPreference).to.be.an.instanceof(ReadPreference); + expect(options.readPreference.mode).to.equal('nearest'); + }); + + it('supports ReadPreference option in object plain', function () { + const options = parseOptions('mongodb://localhost', { + readPreference: { mode: 'nearest', hedge: { enabled: true } } + }); + expect(options.readPreference).to.be.an.instanceof(ReadPreference); + expect(options.readPreference.mode).to.equal('nearest'); + expect(options.readPreference.hedge).to.include({ enabled: true }); + }); + + it('supports ReadPreference option in object proper class', function () { + const tag = { rack: 1 }; + const options = parseOptions('mongodb://localhost', { + readPreference: new ReadPreference('nearest', [tag], { maxStalenessSeconds: 20 }) + }); + expect(options.readPreference).to.be.an.instanceof(ReadPreference); + expect(options.readPreference.mode).to.equal('nearest'); + expect(options.readPreference.tags).to.be.an('array').that.includes(tag); + expect(options.readPreference.maxStalenessSeconds).to.equal(20); + // maxStalenessSeconds sets the minWireVersion + expect(options.readPreference.minWireVersion).to.be.at.least(5); + }); + + it('supports WriteConcern option in url', function () { + const options = parseOptions('mongodb://localhost/?w=3'); + expect(options.writeConcern).to.be.an.instanceof(WriteConcern); + expect(options.writeConcern.w).to.equal(3); + }); + + it('supports WriteConcern option in object plain', function () { + const options = parseOptions('mongodb://localhost', { + writeConcern: { w: 'majority', wtimeoutMS: 300 } + }); + expect(options.writeConcern).to.be.an.instanceof(WriteConcern); + expect(options.writeConcern.w).to.equal('majority'); + expect(options.writeConcern.wtimeout).to.equal(300); + }); + + it('supports WriteConcern option in object proper class', function () { + const options = parseOptions('mongodb://localhost', { + writeConcern: new WriteConcern(5, 200, true) + }); + expect(options.writeConcern).to.be.an.instanceof(WriteConcern); + expect(options.writeConcern.w).to.equal(5); + expect(options.writeConcern.wtimeout).to.equal(200); + expect(options.writeConcern.j).to.equal(true); + }); + + it('supports ReadConcern option in url', function () { + const options = parseOptions('mongodb://localhost/?readConcernLevel=available'); + expect(options.readConcern).to.be.an.instanceof(ReadConcern); + expect(options.readConcern.level).to.equal('available'); + }); + + it('supports ReadConcern option in object plain', function () { + const options = parseOptions('mongodb://localhost', { + readConcern: { level: 'linearizable' } + }); + expect(options.readConcern).to.be.an.instanceof(ReadConcern); + expect(options.readConcern.level).to.equal('linearizable'); + }); + + it('supports ReadConcern option in object proper class', function () { + const options = parseOptions('mongodb://localhost', { + readConcern: new ReadConcern('snapshot') + }); + expect(options.readConcern).to.be.an.instanceof(ReadConcern); + expect(options.readConcern.level).to.equal('snapshot'); + }); + + it('supports Credentials option in url', function () { + const options = parseOptions('mongodb://USERNAME:PASSWORD@localhost/'); + expect(options.credentials).to.be.an.instanceof(MongoCredentials); + expect(options.credentials.username).to.equal('USERNAME'); + expect(options.credentials.password).to.equal('PASSWORD'); + }); + + it('supports Credentials option in auth object plain', function () { + const options = parseOptions('mongodb://localhost/', { + auth: { username: 'USERNAME', password: 'PASSWORD' } + }); + expect(options.credentials).to.be.an.instanceof(MongoCredentials); + expect(options.credentials.username).to.equal('USERNAME'); + expect(options.credentials.password).to.equal('PASSWORD'); + }); + + it('supports Credentials option in object plain', function () { + // top-level username and password are supported because + // they represent the authority section of connection string + const options = parseOptions('mongodb://localhost/', { + username: 'USERNAME', + password: 'PASSWORD' + }); + expect(options.credentials).to.be.an.instanceof(MongoCredentials); + expect(options.credentials.username).to.equal('USERNAME'); + expect(options.credentials.password).to.equal('PASSWORD'); + }); +});