diff --git a/.evergreen/config.in.yml b/.evergreen/config.in.yml index 2b5fa1eada..cb327275fb 100644 --- a/.evergreen/config.in.yml +++ b/.evergreen/config.in.yml @@ -113,6 +113,18 @@ functions: args: - .evergreen/run-tests.sh + "run runtime independence tests": + - <<: *assume_secrets_manager_role + - command: subprocess.exec + type: test + params: + add_expansions_to_env: true + working_dir: "src" + timeout_secs: 300 + binary: bash + args: + - .evergreen/run-runtime-independence-tests.sh + "perf send": - command: s3.put params: @@ -1237,3 +1249,8 @@ buildvariants: run_on: ubuntu2204-small tasks: - .alpine-fle + - name: Runtime Independence Test + display_name: Runtime Independence Test + run_on: ubuntu2204-small + tasks: + - .runtime-independence diff --git a/.evergreen/config.yml b/.evergreen/config.yml index e5f5c0962e..f8cb5a6324 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -88,6 +88,19 @@ functions: binary: bash args: - .evergreen/run-tests.sh + run runtime independence tests: + - command: ec2.assume_role + params: + role_arn: ${DRIVERS_SECRETS_ARN} + - command: subprocess.exec + type: test + params: + add_expansions_to_env: true + working_dir: src + timeout_secs: 300 + binary: bash + args: + - .evergreen/run-runtime-independence-tests.sh perf send: - command: s3.put params: @@ -2021,6 +2034,20 @@ tasks: - func: bootstrap mongo-orchestration - func: assume secrets manager role - func: build and test alpine FLE + - name: test-runtime-independence + tags: + - runtime-independence + commands: + - command: expansions.update + type: setup + params: + updates: + - {key: NODE_LTS_VERSION, value: '24'} + - {key: VERSION, value: latest} + - {key: TOPOLOGY, value: replica_set} + - func: install dependencies + - func: bootstrap mongo-orchestration + - func: run runtime independence tests - name: test-latest-server-noauth tags: - latest @@ -2858,6 +2885,11 @@ buildvariants: run_on: ubuntu2204-small tasks: - .alpine-fle + - name: Runtime Independence Test + display_name: Runtime Independence Test + run_on: ubuntu2204-small + tasks: + - .runtime-independence - name: rhel80-large-iron display_name: rhel8 Node20.19.0 run_on: rhel80-large diff --git a/.evergreen/generate_evergreen_tasks.js b/.evergreen/generate_evergreen_tasks.js index ff977cdede..b812611d1b 100644 --- a/.evergreen/generate_evergreen_tasks.js +++ b/.evergreen/generate_evergreen_tasks.js @@ -649,6 +649,21 @@ SINGLETON_TASKS.push({ ] }); +SINGLETON_TASKS.push({ + name: 'test-runtime-independence', + tags: ['runtime-independence'], + commands: [ + updateExpansions({ + NODE_LTS_VERSION: LATEST_LTS, + VERSION: 'latest', + TOPOLOGY: 'replica_set' + }), + { func: 'install dependencies' }, + { func: 'bootstrap mongo-orchestration' }, + { func: 'run runtime independence tests' } + ] +}); + function addPerformanceTasks() { const makePerfTask = (name, MONGODB_CLIENT_OPTIONS) => ({ name, diff --git a/.evergreen/run-runtime-independence-tests.sh b/.evergreen/run-runtime-independence-tests.sh new file mode 100644 index 0000000000..ff75aec814 --- /dev/null +++ b/.evergreen/run-runtime-independence-tests.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# set -o xtrace # Write all commands first to stderr +set -o errexit # Exit the script with error if any of the commands fail + +# Supported/used environment variables: +# AUTH Set to enable authentication. Defaults to "noauth" +# MONGODB_URI Set the suggested connection MONGODB_URI (including credentials and topology info) +# MARCH Machine Architecture. Defaults to lowercase uname -m +# SKIP_DEPS Skip installing dependencies + +AUTH=${AUTH:-noauth} +MONGODB_URI=${MONGODB_URI:-} +SKIP_DEPS=${SKIP_DEPS:-true} + +# run tests +echo "Running $AUTH tests, connecting to $MONGODB_URI" + +if [[ -z "${SKIP_DEPS}" ]]; then + source "${PROJECT_DIRECTORY}/.evergreen/install-dependencies.sh" +else + source $DRIVERS_TOOLS/.evergreen/init-node-and-npm-env.sh +fi + +export AUTH=$AUTH +export MONGODB_API_VERSION=${MONGODB_API_VERSION} +export MONGODB_URI=${MONGODB_URI} + +npm run check:runtime-independence diff --git a/package.json b/package.json index 6daf61d6d5..8a8d7b38af 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "check:csfle": "nyc mocha --config test/mocha_mongodb.js test/integration/client-side-encryption", "check:snappy": "nyc mocha test/unit/assorted/snappy.test.js", "check:x509": "nyc mocha test/manual/x509_auth.test.ts", + "check:runtime-independence": "ts-node test/tools/runner/vm_runner.ts test/integration/change-streams/change_stream.test.ts", "fix:eslint": "npm run check:eslint -- --fix", "prepare": "node etc/prepare.js", "preview:docs": "ts-node etc/docs/preview.ts", diff --git a/test/tools/runner/vm_runner.ts b/test/tools/runner/vm_runner.ts new file mode 100644 index 0000000000..3109ac8e1a --- /dev/null +++ b/test/tools/runner/vm_runner.ts @@ -0,0 +1,249 @@ +/* eslint-disable no-restricted-globals, @typescript-eslint/no-require-imports */ + +import * as fs from 'node:fs'; +import { isBuiltin } from 'node:module'; +import * as path from 'node:path'; +import * as vm from 'node:vm'; + +import * as Mocha from 'mocha'; +import * as ts from 'typescript'; + +import * as mochaConfiguration from '../../mocha_mongodb'; + +const mocha = new Mocha(mochaConfiguration); +mocha.suite.emit('pre-require', global, 'host-context', mocha); + +// mocha hooks and custom "require" modules needs to be loaded and injected separately +require('./throw_rejections.cjs'); +require('./chai_addons.ts'); +require('./ee_checker.ts'); +for (const path of ['./hooks/leak_checker.ts', './hooks/configuration.ts']) { + const mod = require(path); + const hooks = mod.mochaHooks; + const register = (hookName, globalFn) => { + if (hooks[hookName]) { + const list = Array.isArray(hooks[hookName]) ? hooks[hookName] : [hooks[hookName]]; + list.forEach(fn => globalFn(fn)); + } + }; + + register('beforeAll', global.before); + register('afterAll', global.after); + register('beforeEach', global.beforeEach); + register('afterEach', global.afterEach); +} + +let compilerOptions: ts.CompilerOptions = { module: ts.ModuleKind.CommonJS }; +const tsConfigPath = path.join(__dirname, '../../tsconfig.json'); +const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile); +if (!configFile.error) { + const parsedConfig = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(tsConfigPath) + ); + compilerOptions = { + ...parsedConfig.options, + module: ts.ModuleKind.CommonJS, + sourceMap: false, + // inline source map for stack traces + inlineSourceMap: true + }; +} else { + throw new Error('tsconfig is missing'); +} + +const moduleCache = new Map(); + +const sandbox = vm.createContext({ + __proto__: null, + + console: console, + AbortController: AbortController, + AbortSignal: AbortSignal, + Date: global.Date, + Error: global.Error, + URL: global.URL, + URLSearchParams: global.URLSearchParams, + queueMicrotask: queueMicrotask, + performance: global.performance, + + process: process, + + context: global.context, + describe: global.describe, + xdescribe: global.xdescribe, + it: global.it, + xit: global.xit, + before: global.before, + after: global.after, + beforeEach: global.beforeEach, + afterEach: global.afterEach +}); + +function createProxiedRequire(parentPath: string) { + const parentDir = path.dirname(parentPath); + + return function sandboxRequire(moduleIdentifier: string) { + // allow all code modules be imported by the host environment + if (isBuiltin(moduleIdentifier)) { + return require(moduleIdentifier); + } + + // list of dependencies we want to import from within the sandbox + const sandboxedDependencies = ['bson']; + const isSandboxedDep = sandboxedDependencies.some( + dep => moduleIdentifier === dep || moduleIdentifier.startsWith(`${dep}/`) + ); + if (!moduleIdentifier.startsWith('.') && !isSandboxedDep) { + return require(moduleIdentifier); + } + + // require.resolve throws if module can't be loaded, let it bubble up + const fullPath = require.resolve(moduleIdentifier, { paths: [parentDir] }); + return loadInSandbox(fullPath); + }; +} + +function loadInSandbox(filepath: string) { + const realPath = fs.realpathSync(filepath); + + if (moduleCache.has(realPath)) { + return moduleCache.get(realPath); + } + + // clientmetadata requires package.json to fetch driver's version + if (realPath.endsWith('package.json')) { + const jsonContent = JSON.parse(fs.readFileSync(realPath, 'utf8')); + moduleCache.set(realPath, jsonContent); + return jsonContent; + } + + // js-bson is allowed to use Buffer, only ./src/ is not + const isSourceFile = realPath.includes('/src/') && !realPath.includes('node_modules'); + const isTestFile = realPath.includes('.test.ts') || realPath.includes('.test.js'); + + let localBuffer = Buffer; + if (isSourceFile && !isTestFile) { + localBuffer = new Proxy(Buffer, { + get(target, prop, receiver) { + if ( + typeof prop === 'symbol' || + ['prototype', 'constructor', 'name', 'inspect'].includes(prop as string) + ) { + return Reflect.get(target, prop, receiver); + } + + throw new Error( + `Forbidden: 'Buffer' usage is not allowed in source files. Use Uint8Array instead. File: ${realPath}` + ); + }, + construct() { + throw new Error( + `Forbidden: 'Buffer' usage is not allowed in source files. Use Uint8Array instead. File: ${realPath}` + ); + } + }) as any; + } + + const content = fs.readFileSync(realPath, 'utf8'); + let executableCode: string; + if (realPath.endsWith('.ts')) { + executableCode = ts.transpileModule(content, { + compilerOptions: compilerOptions, + fileName: realPath + }).outputText; + } else { + // .js or .cjs should work just fine + executableCode = content; + } + + const exportsContainer = {}; + const localModule = { exports: exportsContainer }; + const localRequire = createProxiedRequire(realPath); + const filename = realPath; + const dirname = path.dirname(realPath); + + // prevent recursion + moduleCache.set(realPath, localModule.exports); + + try { + const wrapper = `(function(exports, require, module, __filename, __dirname, Buffer) { + ${executableCode} + })`; + const script = new vm.Script(wrapper, { filename: realPath }); + const fn = script.runInContext(sandbox); + + fn(localModule.exports, localRequire, localModule, filename, dirname, localBuffer); + + const result = localModule.exports; + + const isBSON = realPath.includes('node_modules/bson'); + const isError = realPath.includes('src/error.ts'); + + if (isBSON || isError) { + for (const [key, value] of Object.entries(result)) { + if (typeof value === 'function' && value.name) { + // force instanceof to work across contexts by defining custom `instanceof` function + Object.defineProperty(value, Symbol.hasInstance, { + value: (i: any) => { + if (!i) return false; + // use isPrototypeOf to avoid triggering the 'instanceof' trap recursively + return ( + i.constructor.name === value.name || + Object.prototype.isPrototypeOf.call(value.prototype, i) + ); + }, + configurable: true + }); + + // also inject into global for easier access in tests + (sandbox as any)[key] = value; + } + } + } + + moduleCache.set(realPath, result); + + return result; + } catch (err: any) { + moduleCache.delete(realPath); + console.error(`Error running ${realPath} in sandbox:`, err); + throw err; + } +} + +// use it similar to regular mocha: +// mocha --config test/mocha_mongodb.js test/integration +// ts-node test/runner/vm_context.ts test/integration +const userArgs = process.argv.slice(2); +const searchTargets = userArgs.length > 0 ? userArgs : ['test']; +const testFiles = searchTargets.flatMap(target => { + try { + const stats = fs.statSync(target); + if (stats.isDirectory()) { + const pattern = path.join(target, '**/*.test.{ts,js}').replace(/\\/g, '/'); + return fs.globSync(pattern); + } + if (stats.isFile()) { + return [target]; + } + } catch { + console.error(`Error: Could not find path "${target}"`); + } + return []; +}); + +if (testFiles.length === 0) { + console.log('No test files found.'); + process.exit(0); +} + +testFiles.forEach(file => { + loadInSandbox(path.resolve(file)); +}); + +console.log('Running Tests...'); +mocha.run(failures => { + process.exitCode = failures ? 1 : 0; +});