UNPKG

mongodb-memory-server-core

Version:

MongoDB Server for testing (core package, without autodownload). The server will allow you to connect your favourite ODM or client library to the MongoDB Server and run parallel integration tests isolated from each other.

558 lines 26.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MongoMemoryReplSet = exports.MongoMemoryReplSetEvents = exports.MongoMemoryReplSetStates = void 0; const tslib_1 = require("tslib"); const events_1 = require("events"); const MongoMemoryServer_1 = require("./MongoMemoryServer"); const utils_1 = require("./util/utils"); const debug_1 = (0, tslib_1.__importDefault)(require("debug")); const mongodb_1 = require("mongodb"); const MongoInstance_1 = require("./util/MongoInstance"); const errors_1 = require("./util/errors"); const tmp = (0, tslib_1.__importStar)(require("tmp")); const fs_1 = require("fs"); const path_1 = require("path"); const log = (0, debug_1.default)('MongoMS:MongoMemoryReplSet'); tmp.setGracefulCleanup(); /** * Enum for "_state" inside "MongoMemoryReplSet" */ var MongoMemoryReplSetStates; (function (MongoMemoryReplSetStates) { MongoMemoryReplSetStates["init"] = "init"; MongoMemoryReplSetStates["running"] = "running"; MongoMemoryReplSetStates["stopped"] = "stopped"; })(MongoMemoryReplSetStates = exports.MongoMemoryReplSetStates || (exports.MongoMemoryReplSetStates = {})); /** * All Events for "MongoMemoryReplSet" */ var MongoMemoryReplSetEvents; (function (MongoMemoryReplSetEvents) { MongoMemoryReplSetEvents["stateChange"] = "stateChange"; })(MongoMemoryReplSetEvents = exports.MongoMemoryReplSetEvents || (exports.MongoMemoryReplSetEvents = {})); /** * Class for managing an replSet */ class MongoMemoryReplSet extends events_1.EventEmitter { constructor(opts = {}) { var _a; super(); /** * All servers this ReplSet instance manages */ this.servers = []; this._state = MongoMemoryReplSetStates.stopped; this._ranCreateAuth = false; this.binaryOpts = Object.assign({}, opts.binary); this.instanceOpts = (_a = opts.instanceOpts) !== null && _a !== void 0 ? _a : []; this.replSetOpts = Object.assign({}, opts.replSet); } /** * Change "this._state" to "newState" and emit "newState" * @param newState The new State to set & emit */ stateChange(newState, ...args) { this._state = newState; this.emit(MongoMemoryReplSetEvents.stateChange, newState, ...args); } /** * Create an instance of "MongoMemoryReplSet" and call start * @param opts Options for the ReplSet */ static create(opts) { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log('create: Called .create() method'); const replSet = new this(Object.assign({}, opts)); yield replSet.start(); return replSet; }); } /** * Get Current state of this class */ get state() { return this._state; } /** * Get & Set "instanceOpts" * @throws if "state" is not "stopped" */ get instanceOpts() { return this._instanceOpts; } set instanceOpts(val) { assertionIsMMSRSState(MongoMemoryReplSetStates.stopped, this._state); this._instanceOpts = val; } /** * Get & Set "binaryOpts" * @throws if "state" is not "stopped" */ get binaryOpts() { return this._binaryOpts; } set binaryOpts(val) { assertionIsMMSRSState(MongoMemoryReplSetStates.stopped, this._state); this._binaryOpts = val; } /** * Get & Set "replSetOpts" * (Applies defaults) * @throws if "state" is not "stopped" */ get replSetOpts() { return this._replSetOpts; } set replSetOpts(val) { assertionIsMMSRSState(MongoMemoryReplSetStates.stopped, this._state); const defaults = { auth: false, args: [], name: 'testset', count: 1, dbName: (0, utils_1.generateDbName)(), ip: '127.0.0.1', spawn: {}, storageEngine: 'ephemeralForTest', configSettings: {}, }; this._replSetOpts = Object.assign(Object.assign({}, defaults), val); (0, utils_1.assertion)(this._replSetOpts.count > 0, new errors_1.ReplsetCountLowError(this._replSetOpts.count)); // setting this for sanity if (typeof this._replSetOpts.auth === 'boolean') { this._replSetOpts.auth = { disable: !this._replSetOpts.auth }; } // do not set default when "disable" is "true" to save execution and memory if (!this._replSetOpts.auth.disable) { this._replSetOpts.auth = (0, utils_1.authDefault)(this._replSetOpts.auth); } } /** * Helper function to determine if "auth" should be enabled * This function expectes to be run after the auth object has been transformed to a object * @returns "true" when "auth" should be enabled */ enableAuth() { if ((0, utils_1.isNullOrUndefined)(this._replSetOpts.auth)) { return false; } (0, utils_1.assertion)(typeof this._replSetOpts.auth === 'object', new errors_1.AuthNotObjectError()); return typeof this._replSetOpts.auth.disable === 'boolean' // if "this._replSetOpts.auth.disable" is defined, use that ? !this._replSetOpts.auth.disable // invert the disable boolean, because "auth" should only be disabled if "disabled = true" : true; // if "this._replSetOpts.auth.disable" is not defined, default to true because "this._replSetOpts.auth" is defined } /** * Returns instance options suitable for a MongoMemoryServer. * @param baseOpts Options to merge with * @param keyfileLocation The Keyfile location if "auth" is used */ getInstanceOpts(baseOpts = {}, keyfileLocation) { const enableAuth = this.enableAuth(); const opts = { auth: enableAuth, args: this._replSetOpts.args, dbName: this._replSetOpts.dbName, ip: this._replSetOpts.ip, replSet: this._replSetOpts.name, storageEngine: this._replSetOpts.storageEngine, }; if (!(0, utils_1.isNullOrUndefined)(keyfileLocation)) { opts.keyfileLocation = keyfileLocation; } if (baseOpts.args) { opts.args = this._replSetOpts.args.concat(baseOpts.args); } if (baseOpts.port) { opts.port = baseOpts.port; } if (baseOpts.dbPath) { opts.dbPath = baseOpts.dbPath; } if (baseOpts.storageEngine) { opts.storageEngine = baseOpts.storageEngine; } if (baseOpts.replicaMemberConfig) { opts.replicaMemberConfig = baseOpts.replicaMemberConfig; } log('getInstanceOpts: instance opts:', opts); return opts; } /** * Returns an mongodb URI that is setup with all replSet servers * @param otherDb add an database into the uri (in mongodb its the auth database, in mongoose its the default database for models) * @param otherIp change the ip in the generated uri, default will otherwise always be "127.0.0.1" * @throws if state is not "running" * @throws if an server doesnt have "instanceInfo.port" defined * @returns an valid mongo URI, by the definition of https://docs.mongodb.com/manual/reference/connection-string/ */ getUri(otherDb, otherIp) { log('getUri:', this.state); switch (this.state) { case MongoMemoryReplSetStates.running: case MongoMemoryReplSetStates.init: break; case MongoMemoryReplSetStates.stopped: default: throw new errors_1.StateError([MongoMemoryReplSetStates.running, MongoMemoryReplSetStates.init], this.state); } const hosts = this.servers .map((s) => { var _a; const port = (_a = s.instanceInfo) === null || _a === void 0 ? void 0 : _a.port; (0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(port), new Error('Instance Port is undefined!')); const ip = otherIp || '127.0.0.1'; return `${ip}:${port}`; }) .join(','); return (0, utils_1.uriTemplate)(hosts, undefined, (0, utils_1.generateDbName)(otherDb), [ `replicaSet=${this._replSetOpts.name}`, ]); } /** * Start underlying `mongod` instances. * @throws if state is already "running" */ start() { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log('start:', this.state); switch (this.state) { case MongoMemoryReplSetStates.stopped: break; case MongoMemoryReplSetStates.running: default: throw new errors_1.StateError([MongoMemoryReplSetStates.stopped], this.state); } this.stateChange(MongoMemoryReplSetStates.init); // this needs to be executed before "setImmediate" yield (0, utils_1.ensureAsync)() .then(() => this.initAllServers()) .then(() => this._initReplSet()) .catch((err) => (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { if (!debug_1.default.enabled('MongoMS:MongoMemoryReplSet')) { console.warn('Starting the MongoMemoryReplSet Instance failed, enable debug log for more information. Error:\n', err); } log('ensureAsync chain threw a Error: ', err); yield this.stop({ doCleanup: false, force: false }); // still try to close the instance that was spawned, without cleanup for investigation this.stateChange(MongoMemoryReplSetStates.stopped); throw err; })); }); } /** * Initialize & start all servers in the replSet */ initAllServers() { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log('initAllServers'); this.stateChange(MongoMemoryReplSetStates.init); if (this.servers.length > 0) { log('initAllServers: lenght of "servers" is higher than 0, starting existing servers'); if (this._ranCreateAuth) { log('initAllServers: "_ranCreateAuth" is true, re-using auth'); const keyfilepath = (0, path_1.resolve)((yield this.ensureKeyFile()).name, 'keyfile'); for (const server of this.servers) { (0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(server.instanceInfo), new errors_1.InstanceInfoError('MongoMemoryReplSet.initAllServers')); (0, utils_1.assertion)(typeof this._replSetOpts.auth === 'object', new errors_1.AuthNotObjectError()); server.instanceInfo.instance.instanceOpts.auth = true; server.instanceInfo.instance.instanceOpts.keyfileLocation = keyfilepath; server.instanceInfo.instance.extraConnectionOptions = { authSource: 'admin', authMechanism: 'SCRAM-SHA-256', auth: { username: this._replSetOpts.auth.customRootName, password: this._replSetOpts.auth.customRootPwd, }, }; } } yield Promise.all(this.servers.map((s) => s.start(true))); log('initAllServers: finished starting existing instances again'); return; } let keyfilePath = undefined; if (this.enableAuth()) { keyfilePath = (0, path_1.resolve)((yield this.ensureKeyFile()).name, 'keyfile'); } // Any servers defined within `_instanceOpts` should be started first as // the user could have specified a `dbPath` in which case we would want to perform // the `replSetInitiate` command against that server. this._instanceOpts.forEach((opts, index) => { log(`initAllServers: starting special server "${index + 1}" of "${this._instanceOpts.length}" from instanceOpts (count: ${this.servers.length + 1}):`, opts); this.servers.push(this._initServer(this.getInstanceOpts(opts, keyfilePath))); }); while (this.servers.length < this._replSetOpts.count) { log(`initAllServers: starting extra server "${this.servers.length + 1}" of "${this._replSetOpts.count}" (count: ${this.servers.length + 1})`); this.servers.push(this._initServer(this.getInstanceOpts(undefined, keyfilePath))); } log('initAllServers: waiting for all servers to finish starting'); // ensures all servers are listening for connection yield Promise.all(this.servers.map((s) => s.start())); log('initAllServers: finished starting all servers initially'); }); } /** * Ensure "_keyfiletmp" is defined * @returns the ensured "_keyfiletmp" value */ ensureKeyFile() { var _a; return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log('ensureKeyFile'); if ((0, utils_1.isNullOrUndefined)(this._keyfiletmp)) { this._keyfiletmp = tmp.dirSync({ mode: 0o766, prefix: 'mongo-mem-keyfile-', unsafeCleanup: true, }); } const keyfilepath = (0, path_1.resolve)(this._keyfiletmp.name, 'keyfile'); // if path does not exist or have no access, create it (or fail) if (!(yield (0, utils_1.statPath)(keyfilepath))) { log('ensureKeyFile: creating Keyfile'); (0, utils_1.assertion)(typeof this._replSetOpts.auth === 'object', new errors_1.AuthNotObjectError()); yield fs_1.promises.writeFile((0, path_1.resolve)(this._keyfiletmp.name, 'keyfile'), (_a = this._replSetOpts.auth.keyfileContent) !== null && _a !== void 0 ? _a : '0123456789', { mode: 0o700 } // this is because otherwise mongodb errors with "permissions are too open" on unix systems ); } return this._keyfiletmp; }); } stop(cleanupOptions) { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log(`stop: called by ${(0, utils_1.isNullOrUndefined)(process.exitCode) ? 'manual' : 'process exit'}`); /** Default to cleanup temporary, but not custom dbpaths */ let cleanup = { doCleanup: true, force: false }; // handle the old way of setting wheter to cleanup or not // TODO: for next major release (9.0), this should be removed if (typeof cleanupOptions === 'boolean') { cleanup.doCleanup = cleanupOptions; } // handle the new way of setting what and how to cleanup if (typeof cleanupOptions === 'object') { cleanup = cleanupOptions; } if (this._state === MongoMemoryReplSetStates.stopped) { log('stop: state is "stopped", trying to stop / kill anyway'); } const successfullyStopped = yield Promise.all(this.servers.map((s) => s.stop({ doCleanup: false, force: false }))) .then(() => { this.stateChange(MongoMemoryReplSetStates.stopped); return true; }) .catch((err) => { log('stop:', err); this.stateChange(MongoMemoryReplSetStates.stopped, err); return false; }); // return early if the instances failed to stop if (!successfullyStopped) { return false; } if (cleanup.doCleanup) { yield this.cleanup(cleanup); } return true; }); } cleanup(options) { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { assertionIsMMSRSState(MongoMemoryReplSetStates.stopped, this._state); log(`cleanup for "${this.servers.length}" servers`); /** Default to doing cleanup, but not forcing it */ let cleanup = { doCleanup: true, force: false }; // handle the old way of setting wheter to cleanup or not // TODO: for next major release (9.0), this should be removed if (typeof options === 'boolean') { cleanup.force = options; } // handle the new way of setting what and how to cleanup if (typeof options === 'object') { cleanup = options; } log(`cleanup:`, cleanup); // dont do cleanup, if "doCleanup" is false if (!cleanup.doCleanup) { log('cleanup: "doCleanup" is set to false'); return; } yield Promise.all(this.servers.map((s) => s.cleanup(cleanup))); // cleanup the keyfile tmpdir if (!(0, utils_1.isNullOrUndefined)(this._keyfiletmp)) { this._keyfiletmp.removeCallback(); this._keyfiletmp = undefined; } this.servers = []; this._ranCreateAuth = false; return; }); } /** * Wait until all instances are running * @throws if state is "stopped" (cannot wait on something that dosnt start) */ waitUntilRunning() { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { yield (0, utils_1.ensureAsync)(); log('waitUntilRunning:', this._state); switch (this._state) { case MongoMemoryReplSetStates.running: // just return immediatly if the replSet is already running return; case MongoMemoryReplSetStates.init: // wait for event "running" yield new Promise((res) => { // the use of "this" here can be done because "on" either binds "this" or uses an arrow function function waitRunning(state) { // this is because other states can be emitted multiple times (like stopped & init for auth creation) if (state === MongoMemoryReplSetStates.running) { this.removeListener(MongoMemoryReplSetEvents.stateChange, waitRunning); res(); } } this.on(MongoMemoryReplSetEvents.stateChange, waitRunning); }); return; case MongoMemoryReplSetStates.stopped: default: throw new errors_1.StateError([MongoMemoryReplSetStates.running, MongoMemoryReplSetStates.init], this.state); } }); } /** * Connects to the first server from the list of servers and issues the `replSetInitiate` * command passing in a new replica set configuration object. * @throws if state is not "init" * @throws if "servers.length" is not 1 or above * @throws if package "mongodb" is not installed */ _initReplSet() { var _a, _b, _c; return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log('_initReplSet'); assertionIsMMSRSState(MongoMemoryReplSetStates.init, this._state); (0, utils_1.assertion)(this.servers.length > 0, new Error('One or more servers are required.')); const uris = this.servers.map((server) => server.getUri()); const isInMemory = ((_a = this.servers[0].instanceInfo) === null || _a === void 0 ? void 0 : _a.storageEngine) === 'ephemeralForTest'; const extraOptions = this._ranCreateAuth ? (_c = (_b = this.servers[0].instanceInfo) === null || _b === void 0 ? void 0 : _b.instance.extraConnectionOptions) !== null && _c !== void 0 ? _c : {} : {}; const con = yield mongodb_1.MongoClient.connect(uris[0], Object.assign({ // somehow since mongodb-nodejs 4.0, this option is needed when the server is set to be in a replset directConnection: true }, extraOptions)); log('_initReplSet: connected'); // try-finally to close connection in any case try { const adminDb = con.db('admin'); const members = uris.map((uri, index) => { var _a; return (Object.assign({ _id: index, host: (0, utils_1.getHost)(uri) }, (((_a = this.servers[index].opts.instance) === null || _a === void 0 ? void 0 : _a.replicaMemberConfig) || {}))); }); const rsConfig = { _id: this._replSetOpts.name, members, writeConcernMajorityJournalDefault: !isInMemory, settings: Object.assign({ electionTimeoutMillis: 500 }, this._replSetOpts.configSettings), }; // try-catch because the first "command" can fail try { log('_initReplSet: trying "replSetInitiate"'); yield adminDb.command({ replSetInitiate: rsConfig }); if (this.enableAuth()) { log('_initReplSet: "enableAuth" returned "true"'); yield this._waitForPrimary(undefined, '_initReplSet authIsObject'); // find the primary instance to run createAuth on const primary = this.servers.find((server) => { var _a; return (_a = server.instanceInfo) === null || _a === void 0 ? void 0 : _a.instance.isInstancePrimary; }); (0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(primary), new Error('No Primary found')); // this should be defined at this point, but is checked anyway (thanks to types) (0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(primary.instanceInfo), new errors_1.InstanceInfoError('_initReplSet authIsObject primary')); yield con.close(); // just ensuring that no timeouts happen or conflicts happen yield primary.createAuth(primary.instanceInfo); this._ranCreateAuth = true; } } catch (e) { if (e instanceof mongodb_1.MongoError && e.errmsg == 'already initialized') { log(`_initReplSet: "${e.errmsg}": trying to set old config`); const { config: oldConfig } = yield adminDb.command({ replSetGetConfig: 1 }); log('_initReplSet: got old config:\n', oldConfig); yield adminDb.command({ replSetReconfig: oldConfig, force: true, }); } else { throw e; } } log('_initReplSet: ReplSet-reconfig finished'); yield this._waitForPrimary(undefined, '_initReplSet beforeRunning'); this.stateChange(MongoMemoryReplSetStates.running); log('_initReplSet: running'); } finally { log('_initReplSet: finally closing connection'); yield con.close(); } }); } /** * Create the one Instance (without starting them) * @param instanceOpts Instance Options to use for this instance */ _initServer(instanceOpts) { const serverOpts = { binary: this._binaryOpts, instance: instanceOpts, spawn: this._replSetOpts.spawn, auth: typeof this.replSetOpts.auth === 'object' ? this.replSetOpts.auth : undefined, }; const server = new MongoMemoryServer_1.MongoMemoryServer(serverOpts); return server; } /** * Wait until the replSet has elected a Primary * @param timeout Timeout to not run infinitly, default: 30s * @param where Extra Parameter for logging to know where this function was called * @throws if timeout is reached */ _waitForPrimary(timeout = 1000 * 30, where) { return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () { log('_waitForPrimary: Waiting for a Primary'); let timeoutId; // "race" because not all servers will be a primary yield Promise.race([ ...this.servers.map((server) => new Promise((res, rej) => { const instanceInfo = server.instanceInfo; // this should be defined at this point, but is checked anyway (thanks to types) if ((0, utils_1.isNullOrUndefined)(instanceInfo)) { return rej(new errors_1.InstanceInfoError('_waitForPrimary Primary race')); } instanceInfo.instance.once(MongoInstance_1.MongoInstanceEvents.instancePrimary, res); if (instanceInfo.instance.isInstancePrimary) { log('_waitForPrimary: found instance being already primary'); res(); } })), new Promise((_res, rej) => { timeoutId = setTimeout(() => { Promise.all([...this.servers.map((v) => v.stop())]); // this is not chained with "rej", this is here just so things like jest can exit at some point rej(new errors_1.WaitForPrimaryTimeoutError(timeout, where)); }, timeout); }), ]); if (!(0, utils_1.isNullOrUndefined)(timeoutId)) { clearTimeout(timeoutId); } log('_waitForPrimary: detected one primary instance '); }); } } exports.MongoMemoryReplSet = MongoMemoryReplSet; exports.default = MongoMemoryReplSet; /** * Helper function to de-duplicate state checking for "MongoMemoryReplSetStates" * @param wantedState The State that is wanted * @param currentState The current State ("this._state") */ function assertionIsMMSRSState(wantedState, currentState) { (0, utils_1.assertion)(currentState === wantedState, new errors_1.StateError([wantedState], currentState)); } //# sourceMappingURL=MongoMemoryReplSet.js.map