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.
520 lines • 27 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MongoMemoryServer = exports.MongoMemoryServerStates = exports.MongoMemoryServerEvents = void 0;
const tslib_1 = require("tslib");
const tmp = (0, tslib_1.__importStar)(require("tmp"));
const get_port_1 = (0, tslib_1.__importDefault)(require("get-port"));
const utils_1 = require("./util/utils");
const MongoInstance_1 = require("./util/MongoInstance");
const debug_1 = (0, tslib_1.__importDefault)(require("debug"));
const events_1 = require("events");
const fs_1 = require("fs");
const mongodb_1 = require("mongodb");
const semver_1 = require("semver");
const errors_1 = require("./util/errors");
const os = (0, tslib_1.__importStar)(require("os"));
const log = (0, debug_1.default)('MongoMS:MongoMemoryServer');
tmp.setGracefulCleanup();
/**
* All Events for "MongoMemoryServer"
*/
var MongoMemoryServerEvents;
(function (MongoMemoryServerEvents) {
MongoMemoryServerEvents["stateChange"] = "stateChange";
})(MongoMemoryServerEvents = exports.MongoMemoryServerEvents || (exports.MongoMemoryServerEvents = {}));
/**
* All States for "MongoMemoryServer._state"
*/
var MongoMemoryServerStates;
(function (MongoMemoryServerStates) {
MongoMemoryServerStates["new"] = "new";
MongoMemoryServerStates["starting"] = "starting";
MongoMemoryServerStates["running"] = "running";
MongoMemoryServerStates["stopped"] = "stopped";
})(MongoMemoryServerStates = exports.MongoMemoryServerStates || (exports.MongoMemoryServerStates = {}));
class MongoMemoryServer extends events_1.EventEmitter {
/**
* Create an Mongo-Memory-Sever Instance
* @param opts Mongo-Memory-Sever Options
*/
constructor(opts) {
super();
/**
* The Current State of this instance
*/
this._state = MongoMemoryServerStates.new;
this.opts = Object.assign({}, opts);
// TODO: consider changing this to not be set if "instance.auth" is false in 9.0
if (!(0, utils_1.isNullOrUndefined)(this.opts.auth)) {
// assign defaults
this.auth = (0, utils_1.authDefault)(this.opts.auth);
}
}
/**
* Create an Mongo-Memory-Sever Instance that can be awaited
* @param opts Mongo-Memory-Sever Options
*/
static create(opts) {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
log('create: Called .create() method');
const instance = new MongoMemoryServer(Object.assign({}, opts));
yield instance.start();
return instance;
});
}
/**
* Start the Mongod Instance
* @param forceSamePort Force to use the Same Port, if already an "instanceInfo" exists
* @throws if state is not "new" or "stopped"
*/
start(forceSamePort = false) {
var _a;
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
this.debug('start: Called .start() method');
switch (this._state) {
case MongoMemoryServerStates.new:
case MongoMemoryServerStates.stopped:
break;
case MongoMemoryServerStates.running:
case MongoMemoryServerStates.starting:
default:
throw new errors_1.StateError([MongoMemoryServerStates.new, MongoMemoryServerStates.stopped], this.state);
}
(0, utils_1.assertion)((0, utils_1.isNullOrUndefined)((_a = this._instanceInfo) === null || _a === void 0 ? void 0 : _a.instance.mongodProcess), new Error('Cannot start because "instance.mongodProcess" is already defined!'));
this.stateChange(MongoMemoryServerStates.starting);
yield this._startUpInstance(forceSamePort).catch((err) => (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
var _b;
// add error information on macos-arm because "spawn Unknown system error -86" does not say much
if (err instanceof Error && ((_b = err.message) === null || _b === void 0 ? void 0 : _b.includes('spawn Unknown system error -86'))) {
if (os.platform() === 'darwin' && os.arch() === 'arm64') {
err.message += err.message += ', Is Rosetta Installed and Setup correctly?';
}
}
if (!debug_1.default.enabled('MongoMS:MongoMemoryServer')) {
console.warn('Starting the MongoMemoryServer Instance failed, enable debug log for more information. Error:\n', err);
}
this.debug('_startUpInstance 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(MongoMemoryServerStates.stopped);
throw err;
}));
this.stateChange(MongoMemoryServerStates.running);
this.debug('start: Instance fully Started');
});
}
/**
* Change "this._state" to "newState" and emit "stateChange" with "newState"
* @param newState The new State to set & emit
*/
stateChange(newState) {
this._state = newState;
this.emit(MongoMemoryServerEvents.stateChange, newState);
}
/**
* Debug-log with template applied
* @param msg The Message to log
*/
debug(msg, ...extra) {
var _a, _b;
const port = (_b = (_a = this._instanceInfo) === null || _a === void 0 ? void 0 : _a.port) !== null && _b !== void 0 ? _b : 'unknown';
log(`Mongo[${port}]: ${msg}`, ...extra);
}
/**
* Find an new unlocked port
* @param port An User defined default port
*/
getNewPort(port) {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
const newPort = yield (0, get_port_1.default)({ port });
// only log this message if an custom port was provided
if (port != newPort && typeof port === 'number') {
this.debug(`getNewPort: starting with port "${newPort}", since "${port}" was locked`);
}
return newPort;
});
}
/**
* Construct Instance Starting Options
*/
getStartOptions(forceSamePort = false) {
var _a, _b, _c;
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
this.debug(`getStartOptions: forceSamePort: ${forceSamePort}`);
/** Shortcut to this.opts.instance */
const instOpts = (_a = this.opts.instance) !== null && _a !== void 0 ? _a : {};
/**
* This variable is used for determining if "createAuth" should be run
*/
let isNew = true;
// use pre-defined port if available, otherwise generate a new port
let port = typeof instOpts.port === 'number' ? instOpts.port : undefined;
// if "forceSamePort" is not true, and get a available port
if (!forceSamePort || (0, utils_1.isNullOrUndefined)(port)) {
port = yield this.getNewPort(port);
}
const data = {
port: port,
dbName: (0, utils_1.generateDbName)(instOpts.dbName),
ip: (_b = instOpts.ip) !== null && _b !== void 0 ? _b : '127.0.0.1',
storageEngine: (_c = instOpts.storageEngine) !== null && _c !== void 0 ? _c : 'ephemeralForTest',
replSet: instOpts.replSet,
dbPath: instOpts.dbPath,
tmpDir: undefined,
keyfileLocation: instOpts.keyfileLocation,
};
if ((0, utils_1.isNullOrUndefined)(this._instanceInfo)) {
// create an tmpDir instance if no "dbPath" is given
if (!data.dbPath) {
data.tmpDir = tmp.dirSync({
mode: 0o755,
prefix: 'mongo-mem-',
unsafeCleanup: true,
});
data.dbPath = data.tmpDir.name;
isNew = true; // just to ensure "isNew" is "true" because an new temporary directory got created
}
else {
this.debug(`getStartOptions: Checking if "${data.dbPath}}" (no new tmpDir) already has data`);
const files = yield fs_1.promises.readdir(data.dbPath);
isNew = files.length === 0; // if there are no files in the directory, assume that the database is new
}
}
else {
isNew = false;
}
const enableAuth = (typeof instOpts.auth === 'boolean' ? instOpts.auth : true) && // check if auth is even meant to be enabled
this.authObjectEnable();
const createAuth = enableAuth && // re-use all the checks from "enableAuth"
!(0, utils_1.isNullOrUndefined)(this.auth) && // needs to be re-checked because typescript complains
(this.auth.force || isNew) && // check that either "isNew" or "this.auth.force" is "true"
!instOpts.replSet; // dont run "createAuth" when its an replset, it will be run by the replset controller
return {
data: data,
createAuth: createAuth,
mongodOptions: {
instance: Object.assign(Object.assign({}, data), { args: instOpts.args, auth: enableAuth }),
binary: this.opts.binary,
spawn: this.opts.spawn,
},
};
});
}
/**
* Internal Function to start an instance
* @param forceSamePort Force to use the Same Port, if already an "instanceInfo" exists
* @private
*/
_startUpInstance(forceSamePort = false) {
var _a, _b;
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
this.debug('_startUpInstance: Called MongoMemoryServer._startUpInstance() method');
if (!(0, utils_1.isNullOrUndefined)(this._instanceInfo)) {
this.debug('_startUpInstance: "instanceInfo" already defined, reusing instance');
if (!forceSamePort) {
const newPort = yield this.getNewPort(this._instanceInfo.port);
this._instanceInfo.instance.instanceOpts.port = newPort;
this._instanceInfo.port = newPort;
}
yield this._instanceInfo.instance.start();
return;
}
const { mongodOptions, createAuth, data } = yield this.getStartOptions(forceSamePort);
this.debug(`_startUpInstance: Creating new MongoDB instance with options:`, mongodOptions);
const instance = yield MongoInstance_1.MongoInstance.create(mongodOptions);
this.debug(`_startUpInstance: Instance Started, createAuth: "${createAuth}"`);
this._instanceInfo = Object.assign(Object.assign({}, data), { dbPath: data.dbPath, // because otherwise the types would be incompatible
instance });
// always set the "extraConnectionOptions" when "auth" is enabled, regardless of if "createAuth" gets run
if (this.authObjectEnable() &&
((_a = mongodOptions.instance) === null || _a === void 0 ? void 0 : _a.auth) === true &&
!(0, utils_1.isNullOrUndefined)(this.auth) // extra check again for typescript, because it cant reuse checks from "enableAuth" yet
) {
instance.extraConnectionOptions = {
authSource: 'admin',
authMechanism: 'SCRAM-SHA-256',
auth: {
username: this.auth.customRootName,
password: this.auth.customRootPwd,
},
};
}
// "isNullOrUndefined" because otherwise typescript complains about "this.auth" possibly being not defined
if (!(0, utils_1.isNullOrUndefined)(this.auth) && createAuth) {
this.debug(`_startUpInstance: Running "createAuth" (force: "${this.auth.force}")`);
yield this.createAuth(data);
}
else {
// extra "if" to log when "disable" is set to "true"
if ((_b = this.opts.auth) === null || _b === void 0 ? void 0 : _b.disable) {
this.debug('_startUpInstance: AutomaticAuth.disable is set to "true" skipping "createAuth"');
}
}
});
}
stop(cleanupOptions) {
var _a, _b;
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
this.debug('stop: Called .stop() method');
/** 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;
}
// just return "true" if there was never an instance
if ((0, utils_1.isNullOrUndefined)(this._instanceInfo)) {
this.debug('stop: "instanceInfo" is not defined (never ran?)');
return false;
}
if (this._state === MongoMemoryServerStates.stopped) {
this.debug('stop: state is "stopped", trying to stop / kill anyway');
}
this.debug(`stop: Stopping MongoDB server on port ${this._instanceInfo.port} with pid ${(_b = (_a = this._instanceInfo.instance) === null || _a === void 0 ? void 0 : _a.mongodProcess) === null || _b === void 0 ? void 0 : _b.pid}` // "undefined" would say more than ""
);
yield this._instanceInfo.instance.stop();
this.stateChange(MongoMemoryServerStates.stopped);
if (cleanup.doCleanup) {
yield this.cleanup(cleanup);
}
return true;
});
}
cleanup(options) {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
assertionIsMMSState(MongoMemoryServerStates.stopped, this.state);
/** 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;
}
this.debug(`cleanup:`, cleanup);
// dont do cleanup, if "doCleanup" is false
if (!cleanup.doCleanup) {
this.debug('cleanup: "doCleanup" is set to false');
return;
}
if ((0, utils_1.isNullOrUndefined)(this._instanceInfo)) {
this.debug('cleanup: "instanceInfo" is undefined');
return;
}
(0, utils_1.assertion)((0, utils_1.isNullOrUndefined)(this._instanceInfo.instance.mongodProcess), new Error('Cannot cleanup because "instance.mongodProcess" is still defined'));
const tmpDir = this._instanceInfo.tmpDir;
if (!(0, utils_1.isNullOrUndefined)(tmpDir)) {
this.debug(`cleanup: removing tmpDir at ${tmpDir.name}`);
tmpDir.removeCallback();
}
if (cleanup.force) {
const dbPath = this._instanceInfo.dbPath;
const res = yield (0, utils_1.statPath)(dbPath);
if ((0, utils_1.isNullOrUndefined)(res)) {
this.debug(`cleanup: force is true, but path "${dbPath}" dosnt exist anymore`);
}
else {
(0, utils_1.assertion)(res.isDirectory(), new Error('Defined dbPath is not an directory'));
if ((0, semver_1.lt)(process.version, '14.14.0')) {
// this has to be used for 12.10 - 14.13 (inclusive) because ".rm" did not exist yet
yield fs_1.promises.rmdir(dbPath, { recursive: true, maxRetries: 1 });
}
else {
// this has to be used for 14.14+ (inclusive) because ".rmdir" and "recursive" got deprecated (DEP0147)
yield fs_1.promises.rm(dbPath, { recursive: true, maxRetries: 1 });
}
}
}
this.stateChange(MongoMemoryServerStates.new); // reset "state" to new, because the dbPath got removed
this._instanceInfo = undefined;
});
}
/**
* Get Information about the currently running instance, if it is not running it returns "undefined"
*/
get instanceInfo() {
return this._instanceInfo;
}
/**
* Get Current state of this class
*/
get state() {
return this._state;
}
/**
* Ensure that the instance is running
* -> throws if instance cannot be started
*/
ensureInstance() {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
this.debug('ensureInstance: Called .ensureInstance() method');
switch (this._state) {
case MongoMemoryServerStates.running:
if (this._instanceInfo) {
return this._instanceInfo;
}
throw new errors_1.EnsureInstanceError(true);
case MongoMemoryServerStates.new:
case MongoMemoryServerStates.stopped:
break;
case MongoMemoryServerStates.starting:
return new Promise((res, rej) => this.once(MongoMemoryServerEvents.stateChange, (state) => {
if (state != MongoMemoryServerStates.running) {
rej(new Error(`"ensureInstance" waited for "running" but got an different state: "${state}"`));
return;
}
// this assertion is mainly for types (typescript otherwise would complain that "_instanceInfo" might be "undefined")
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(this._instanceInfo), new Error('InstanceInfo is undefined!'));
res(this._instanceInfo);
}));
default:
throw new errors_1.StateError([
MongoMemoryServerStates.running,
MongoMemoryServerStates.new,
MongoMemoryServerStates.stopped,
MongoMemoryServerStates.starting,
], this.state);
}
this.debug('ensureInstance: no running instance, calling "start()" command');
yield this.start();
this.debug('ensureInstance: "start()" command was succesfully resolved');
// check again for 1. Typescript-type reasons and 2. if .start failed to throw an error
if (!this._instanceInfo) {
throw new errors_1.EnsureInstanceError(false);
}
return this._instanceInfo;
});
}
/**
* Generate the Connection string used by mongodb
* @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" (or "starting")
* @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) {
this.debug('getUri:', this.state, otherDb, otherIp);
switch (this.state) {
case MongoMemoryServerStates.running:
case MongoMemoryServerStates.starting:
break;
case MongoMemoryServerStates.stopped:
default:
throw new errors_1.StateError([MongoMemoryServerStates.running, MongoMemoryServerStates.starting], this.state);
}
assertionInstanceInfo(this._instanceInfo);
return (0, utils_1.uriTemplate)(otherIp || '127.0.0.1', this._instanceInfo.port, (0, utils_1.generateDbName)(otherDb));
}
/**
* Create the Root user and additional users using the [localhost exception](https://www.mongodb.com/docs/manual/core/localhost-exception/#std-label-localhost-exception)
* This Function assumes "this.opts.auth" is already processed into "this.auth"
* @param data Used to get "ip" and "port"
* @internal
*/
createAuth(data) {
var _a, _b, _c, _d;
return (0, tslib_1.__awaiter)(this, void 0, void 0, function* () {
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(this.auth), new Error('"createAuth" got called, but "this.auth" is undefined!'));
assertionInstanceInfo(this._instanceInfo);
this.debug('createAuth: options:', this.auth);
let con = yield mongodb_1.MongoClient.connect((0, utils_1.uriTemplate)(data.ip, data.port, 'admin'));
try {
let db = con.db('admin'); // just to ensure it is actually the "admin" database AND to have the "Db" data
// Create the root user
this.debug(`createAuth: Creating Root user, name: "${this.auth.customRootName}"`);
yield db.command({
createUser: this.auth.customRootName,
pwd: this.auth.customRootPwd,
mechanisms: ['SCRAM-SHA-256'],
customData: {
createdBy: 'mongodb-memory-server',
as: 'ROOTUSER',
},
roles: ['root'],
// "writeConcern" is needced, otherwise replset servers might fail with "auth failed: such user does not exist"
writeConcern: {
w: 'majority',
},
});
if (this.auth.extraUsers.length > 0) {
this.debug(`createAuth: Creating "${this.auth.extraUsers.length}" Custom Users`);
this.auth.extraUsers.sort((a, b) => {
if (a.database === 'admin') {
return -1; // try to make all "admin" at the start of the array
}
return a.database === b.database ? 0 : 1; // "0" to sort all databases that are the same after each other, and "1" to for pushing it back
});
// reconnecting the database because the root user now exists and the "localhost exception" only allows the first user
yield con.close();
con = yield mongodb_1.MongoClient.connect(this.getUri('admin'), (_a = this._instanceInfo.instance.extraConnectionOptions) !== null && _a !== void 0 ? _a : {});
db = con.db('admin');
for (const user of this.auth.extraUsers) {
user.database = (0, utils_1.isNullOrUndefined)(user.database) ? 'admin' : user.database;
// just to have not to call "con.db" everytime in the loop if its the same
if (user.database !== db.databaseName) {
db = con.db(user.database);
}
this.debug('createAuth: Creating User: ', user);
yield db.command({
createUser: user.createUser,
pwd: user.pwd,
customData: Object.assign(Object.assign({}, user.customData), { createdBy: 'mongodb-memory-server', as: 'EXTRAUSER' }),
roles: user.roles,
authenticationRestrictions: (_b = user.authenticationRestrictions) !== null && _b !== void 0 ? _b : [],
mechanisms: (_c = user.mechanisms) !== null && _c !== void 0 ? _c : ['SCRAM-SHA-256'],
digestPassword: (_d = user.digestPassword) !== null && _d !== void 0 ? _d : true,
// "writeConcern" is needced, otherwise replset servers might fail with "auth failed: such user does not exist"
writeConcern: {
w: 'majority',
},
});
}
}
}
finally {
// close connection in any case (even if throwing a error or being successfull)
yield con.close();
}
});
}
/**
* Helper function to determine if the "auth" object is set and not to be disabled
* This function expectes to be run after the auth object has been transformed to a object
* @returns "true" when "auth" should be enabled
*/
authObjectEnable() {
if ((0, utils_1.isNullOrUndefined)(this.auth)) {
return false;
}
return typeof this.auth.disable === 'boolean' // if "this._replSetOpts.auth.disable" is defined, use that
? !this.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
}
}
exports.MongoMemoryServer = MongoMemoryServer;
exports.default = MongoMemoryServer;
/**
* This function is to de-duplicate code
* -> this couldnt be included in the class, because "asserts this.instanceInfo" is not allowed
* @param val this.instanceInfo
*/
function assertionInstanceInfo(val) {
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(val), new Error('"instanceInfo" is undefined'));
}
/**
* Helper function to de-duplicate state checking for "MongoMemoryServerStates"
* @param wantedState The State that is wanted
* @param currentState The current State ("this._state")
*/
function assertionIsMMSState(wantedState, currentState) {
(0, utils_1.assertion)(currentState === wantedState, new errors_1.StateError([wantedState], currentState));
}
//# sourceMappingURL=MongoMemoryServer.js.map