adapt-authoring-mongodb 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,13 @@
6
6
  "description": "Not a valid ObjectId",
7
7
  "statusCode": 400
8
8
  },
9
+ "MONGO_BLOCKED_OPERATOR": {
10
+ "data": {
11
+ "operator": "The blocked operator"
12
+ },
13
+ "description": "A query contained a blocked MongoDB operator that could allow arbitrary code execution",
14
+ "statusCode": 400
15
+ },
9
16
  "MONGO_CONN_FAILED": {
10
17
  "data": {
11
18
  "error": "The error message"
@@ -1,9 +1,6 @@
1
1
  import { AbstractModule } from 'adapt-authoring-core'
2
2
  import { MongoClient } from 'mongodb'
3
- import { convertObjectIds } from './utils/convertObjectIds.js'
4
- import { findDuplicates } from './utils/findDuplicates.js'
5
- import { isValidObjectId } from './utils/isValidObjectId.js'
6
- import { parseObjectId } from './utils/parseObjectId.js'
3
+ import { findDuplicates, isValidObjectId, parseObjectId, processParams } from './utils.js'
7
4
  /**
8
5
  * Represents a single MongoDB server instance
9
6
  * @memberof mongodb
@@ -93,25 +90,6 @@ class MongoDBModule extends AbstractModule {
93
90
  }
94
91
  }
95
92
 
96
- /**
97
- * Makes sure options are in the correct format.
98
- * @param {Object} options The options to parse
99
- */
100
- parseOptions (options) {
101
- if (!options) {
102
- return
103
- }
104
- ['limit', 'skip'].forEach(o => {
105
- if (options[o] === undefined) return
106
- try {
107
- options[o] = parseInt(options[o])
108
- } catch (e) {
109
- this.log('warn', `value for option '${o}' is in an unexpected format and will be ignored`)
110
- delete options[o]
111
- }
112
- })
113
- }
114
-
115
93
  /**
116
94
  * Adds a new object to the database
117
95
  * @param {String} collectionName The name of the MongoDB collection
@@ -122,14 +100,9 @@ class MongoDBModule extends AbstractModule {
122
100
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#insertOne
123
101
  */
124
102
  async insert (collectionName, data, options = {}) {
125
- const { preserveId, ...mongoOptions } = options
126
- convertObjectIds(data)
127
- this.parseOptions(mongoOptions)
128
- if (!preserveId) {
129
- delete data._id
130
- }
103
+ const p = processParams({ data, options })
131
104
  try {
132
- const { insertedId } = await this.getCollection(collectionName).insertOne(data, mongoOptions)
105
+ const { insertedId } = await this.getCollection(collectionName).insertOne(p.data, p.options)
133
106
  const [doc] = await this.find(collectionName, { _id: insertedId })
134
107
  return doc
135
108
  } catch (e) {
@@ -147,17 +120,34 @@ class MongoDBModule extends AbstractModule {
147
120
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#find
148
121
  */
149
122
  async find (collectionName, query, options) {
150
- convertObjectIds(query)
151
- this.parseOptions(options)
123
+ const p = processParams({ query, options })
152
124
  try {
153
- const cursor = this.getCollection(collectionName).find(query, options)
154
- return options?.returnCursor === true ? cursor : await cursor.toArray()
125
+ const cursor = this.getCollection(collectionName).find(p.query, p.options)
126
+ return p.options?.returnCursor === true ? cursor : await cursor.toArray()
155
127
  } catch (e) {
156
128
  this.log('error', `failed to find docs, ${e.message}`)
157
129
  throw this.getError(collectionName, 'find', e)
158
130
  }
159
131
  }
160
132
 
133
+ /**
134
+ * Counts the documents matching a query. Normalises ObjectId strings the same way as {@link MongoDBModule#find} so callers get a count consistent with the documents a find would return.
135
+ * @param {String} collectionName The name of the MongoDB collection
136
+ * @param {Object} query
137
+ * @param {external:MongoDBCountDocumentsOptions} options Options to pass to the MongoDB driver
138
+ * @return {Promise} Resolves with the document count
139
+ * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#countDocuments
140
+ */
141
+ async count (collectionName, query, options) {
142
+ convertObjectIds(query)
143
+ try {
144
+ return await this.getCollection(collectionName).countDocuments(query, options)
145
+ } catch (e) {
146
+ this.log('error', `failed to count docs, ${e.message}`)
147
+ throw this.getError(collectionName, 'count', e)
148
+ }
149
+ }
150
+
161
151
  /**
162
152
  * Updates an existing object in the database
163
153
  * @param {String} collectionName The name of the MongoDB collection
@@ -168,15 +158,9 @@ class MongoDBModule extends AbstractModule {
168
158
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#findOneAndUpdate
169
159
  */
170
160
  async update (collectionName, query, data, options) {
171
- const opts = Object.assign({ includeResultMetadata: false, returnDocument: 'after' }, options)
172
- this.parseOptions(opts)
173
- convertObjectIds(query)
174
- convertObjectIds(data)
175
- // MongoDB doesn't like the explicit setting of _id
176
- delete data._id
177
- if (data.$set) delete data.$set._id
161
+ const p = processParams({ query, data, options: { includeResultMetadata: false, returnDocument: 'after', ...options } })
178
162
  try {
179
- return await this.getCollection(collectionName).findOneAndUpdate(query, data, opts)
163
+ return await this.getCollection(collectionName).findOneAndUpdate(p.query, p.data, p.options)
180
164
  } catch (e) {
181
165
  this.log('error', `failed to update doc, ${e.message}`)
182
166
  throw this.getError(collectionName, 'update', e)
@@ -192,15 +176,10 @@ class MongoDBModule extends AbstractModule {
192
176
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#updateMany
193
177
  */
194
178
  async updateMany (collectionName, query, data, options) {
195
- this.parseOptions(options)
196
- convertObjectIds(query)
197
- convertObjectIds(data)
198
- // MongoDB doesn't like the explicit setting of _id
199
- delete data._id
200
- if (data.$set) delete data.$set._id
179
+ const p = processParams({ query, data, options })
201
180
  try {
202
- await this.getCollection(collectionName).updateMany(query, data, options)
203
- return this.find(collectionName, query)
181
+ await this.getCollection(collectionName).updateMany(p.query, p.data, p.options)
182
+ return this.find(collectionName, p.query)
204
183
  } catch (e) {
205
184
  this.log('error', `failed to update docs, ${e.message}`)
206
185
  throw this.getError(collectionName, 'update', e)
@@ -217,15 +196,9 @@ class MongoDBModule extends AbstractModule {
217
196
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#findOneAndReplace
218
197
  */
219
198
  async replace (collectionName, query, data, options) {
220
- const opts = Object.assign({ includeResultMetadata: false, returnDocument: 'after' }, options)
221
- convertObjectIds(query)
222
- convertObjectIds(data)
223
- this.parseOptions(options)
224
- // MongoDB doesn't like the explicit setting of _id
225
- delete data._id
226
- if (data.$set) delete data.$set._id
199
+ const p = processParams({ query, data, options: { includeResultMetadata: false, returnDocument: 'after', ...options } })
227
200
  try {
228
- return await this.getCollection(collectionName).findOneAndReplace(query, data, opts)
201
+ return await this.getCollection(collectionName).findOneAndReplace(p.query, p.data, p.options)
229
202
  } catch (e) {
230
203
  this.log('error', `failed to replace doc, ${e.message}`)
231
204
  throw this.getError(collectionName, 'replace', e)
@@ -241,10 +214,9 @@ class MongoDBModule extends AbstractModule {
241
214
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#deleteOne
242
215
  */
243
216
  async delete (collectionName, query, options) {
244
- convertObjectIds(query)
245
- this.parseOptions(options)
217
+ const p = processParams({ query, options })
246
218
  try {
247
- await this.getCollection(collectionName).deleteOne(query, options)
219
+ await this.getCollection(collectionName).deleteOne(p.query, p.options)
248
220
  } catch (e) {
249
221
  this.log('error', `failed to delete doc, ${e.message}`)
250
222
  throw this.getError(collectionName, 'delete', e)
@@ -260,10 +232,9 @@ class MongoDBModule extends AbstractModule {
260
232
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#deleteMany
261
233
  */
262
234
  async deleteMany (collectionName, query, options) {
263
- convertObjectIds(query)
264
- this.parseOptions(options)
235
+ const p = processParams({ query, options })
265
236
  try {
266
- await this.getCollection(collectionName).deleteMany(query, options)
237
+ await this.getCollection(collectionName).deleteMany(p.query, p.options)
267
238
  } catch (e) {
268
239
  this.log('error', `failed to delete docs, ${e.message}`)
269
240
  throw this.getError(collectionName, 'delete', e)
package/lib/typedefs.js CHANGED
@@ -7,6 +7,12 @@
7
7
  * @external MongoDBCollection
8
8
  * @see {@link https://mongodb.github.io/node-mongodb-native/4.2/api/classes/Collection.html}
9
9
  */
10
+ /**
11
+ * Options passed to the countDocuments function
12
+ * @memberof mongodb
13
+ * @external MongoDBCountDocumentsOptions
14
+ * @see {@link https://mongodb.github.io/node-mongodb-native/4.2/interfaces/CountDocumentsOptions.html}
15
+ */
10
16
  /**
11
17
  * Options passed to the createIndex function
12
18
  * @memberof mongodb
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Operators that allow arbitrary JavaScript execution on the MongoDB server.
3
+ * @type {Set<string>}
4
+ */
5
+ const BLOCKED_OPERATORS = new Set(['$where', '$accumulator', '$function'])
6
+
7
+ /**
8
+ * Recursively checks a MongoDB query object for dangerous operators that could
9
+ * allow arbitrary code execution on the server.
10
+ * @param {*} input The query object (or nested value) to check
11
+ * @param {ErrorsModule} errors The app errors object
12
+ * @throws {AdaptError} If a blocked operator is found
13
+ * @memberof mongodb
14
+ */
15
+ export function assertSafeQuery (input, errors) {
16
+ if (Array.isArray(input)) {
17
+ for (const item of input) {
18
+ assertSafeQuery(item, errors)
19
+ }
20
+ return
21
+ }
22
+ if (input === null || typeof input !== 'object') {
23
+ return
24
+ }
25
+ for (const key of Object.keys(input)) {
26
+ if (BLOCKED_OPERATORS.has(key)) {
27
+ throw errors.MONGO_BLOCKED_OPERATOR.setData({ operator: key })
28
+ }
29
+ assertSafeQuery(input[key], errors)
30
+ }
31
+ }
@@ -0,0 +1,71 @@
1
+ import { App, isObject } from 'adapt-authoring-core'
2
+ import { assertSafeQuery } from './assertSafeQuery.js'
3
+ import { convertObjectIds } from './convertObjectIds.js'
4
+
5
+ function deepClone (v) {
6
+ if (Array.isArray(v)) return v.map(deepClone)
7
+ if (v !== null && typeof v === 'object' && v.constructor === Object) {
8
+ return Object.fromEntries(Object.entries(v).map(([k, v2]) => [k, deepClone(v2)]))
9
+ }
10
+ return v
11
+ }
12
+
13
+ /**
14
+ * Validators for each allowed MongoDB driver option.
15
+ * Each function returns a parsed value if valid, or undefined to strip the option.
16
+ * @type {Object<string, function>}
17
+ */
18
+ const OPTION_VALIDATORS = {
19
+ collation: v => isObject(v) ? v : undefined,
20
+ includeResultMetadata: v => typeof v === 'boolean' ? v : undefined,
21
+ limit: v => { const n = Number(v); return Number.isInteger(n) ? n : undefined },
22
+ projection: v => isObject(v) ? v : undefined,
23
+ returnCursor: v => typeof v === 'boolean' ? v : undefined,
24
+ returnDocument: v => v === 'before' || v === 'after' ? v : undefined,
25
+ skip: v => { const n = Number(v); return Number.isInteger(n) ? n : undefined },
26
+ sort: v => isObject(v) ? v : undefined,
27
+ upsert: v => typeof v === 'boolean' ? v : undefined
28
+ }
29
+
30
+ /**
31
+ * Validates and normalises query, data, and options before a database operation.
32
+ * Returns deep copies so the caller's originals are not mutated.
33
+ * @param {Object} params
34
+ * @param {Object} [params.query] The query object to validate and convert
35
+ * @param {Object} [params.data] The data object to convert and sanitise
36
+ * @param {Object} [params.options] The options object to parse
37
+ * @returns {{ query: Object, data: Object, options: Object }}
38
+ * @memberof mongodb
39
+ */
40
+ export function processParams ({ query, data, options } = {}) {
41
+ const result = {}
42
+
43
+ if (query) {
44
+ result.query = deepClone(query)
45
+ assertSafeQuery(result.query, App.instance.errors)
46
+ convertObjectIds(result.query)
47
+ }
48
+
49
+ if (data) {
50
+ result.data = deepClone(data)
51
+ convertObjectIds(result.data)
52
+ if (!options?.preserveId) {
53
+ delete result.data._id
54
+ if (result.data.$set) delete result.data.$set._id
55
+ }
56
+ }
57
+
58
+ if (options) {
59
+ result.options = {}
60
+ for (const [key, value] of Object.entries(options)) {
61
+ const validate = OPTION_VALIDATORS[key]
62
+ if (!validate) continue
63
+ const parsed = validate(value)
64
+ if (parsed !== undefined) {
65
+ result.options[key] = parsed
66
+ }
67
+ }
68
+ }
69
+
70
+ return result
71
+ }
package/lib/utils.js CHANGED
@@ -1,5 +1,8 @@
1
+ export { assertSafeQuery } from './utils/assertSafeQuery.js'
2
+ export { convertObjectIds } from './utils/convertObjectIds.js'
1
3
  export { createObjectId } from './utils/createObjectId.js'
4
+ export { findDuplicates, getFieldNames } from './utils/findDuplicates.js'
2
5
  export { isObjectId } from './utils/isObjectId.js'
3
6
  export { isValidObjectId } from './utils/isValidObjectId.js'
4
7
  export { parseObjectId } from './utils/parseObjectId.js'
5
- export { convertObjectIds } from './utils/convertObjectIds.js'
8
+ export { processParams } from './utils/processParams.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-mongodb",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "Module for saving to a MongoDB instance",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-mongodb",
6
6
  "license": "GPL-3.0",
@@ -4,7 +4,7 @@ import MongoDBModule from '../lib/MongoDBModule.js'
4
4
 
5
5
  /**
6
6
  * MongoDBModule extends AbstractModule and requires a running MongoDB connection.
7
- * We test parseOptions and getError in isolation.
7
+ * We test getError in isolation (parseOptions was replaced by processParams utility).
8
8
  */
9
9
 
10
10
  function createInstance () {
@@ -34,48 +34,6 @@ function createInstance () {
34
34
  }
35
35
 
36
36
  describe('MongoDBModule', () => {
37
- describe('#parseOptions()', () => {
38
- it('should parse string limit to integer', () => {
39
- const { instance } = createInstance()
40
- const options = { limit: '10' }
41
- instance.parseOptions(options)
42
- assert.equal(options.limit, 10)
43
- })
44
-
45
- it('should parse string skip to integer', () => {
46
- const { instance } = createInstance()
47
- const options = { skip: '5' }
48
- instance.parseOptions(options)
49
- assert.equal(options.skip, 5)
50
- })
51
-
52
- it('should handle undefined options gracefully', () => {
53
- const { instance } = createInstance()
54
- instance.parseOptions(undefined)
55
- })
56
-
57
- it('should handle options without limit or skip', () => {
58
- const { instance } = createInstance()
59
- const options = { sort: { name: 1 } }
60
- instance.parseOptions(options)
61
- assert.deepEqual(options, { sort: { name: 1 } })
62
- })
63
-
64
- it('should keep numeric limit as-is', () => {
65
- const { instance } = createInstance()
66
- const options = { limit: 10 }
67
- instance.parseOptions(options)
68
- assert.equal(options.limit, 10)
69
- })
70
-
71
- it('should skip undefined limit', () => {
72
- const { instance } = createInstance()
73
- const options = { limit: undefined }
74
- instance.parseOptions(options)
75
- assert.equal(options.limit, undefined)
76
- })
77
- })
78
-
79
37
  describe('#getError()', () => {
80
38
  it('should return MONGO_IMMUTABLE_FIELD for error code 66', () => {
81
39
  const { instance } = createInstance()
@@ -101,4 +59,26 @@ describe('MongoDBModule', () => {
101
59
  assert.equal(result.code, 'MONGO_ERROR')
102
60
  })
103
61
  })
62
+
63
+ describe('#count()', () => {
64
+ it('should convert ObjectId strings in the query before counting', async () => {
65
+ const { instance } = createInstance()
66
+ const countDocuments = mock.fn(async () => 3)
67
+ instance.getCollection = mock.fn(() => ({ countDocuments }))
68
+ const query = { _id: '507f1f77bcf86cd799439011' }
69
+ const result = await instance.count('courses', query)
70
+ assert.equal(result, 3)
71
+ const [received] = countDocuments.mock.calls[0].arguments
72
+ assert.equal(received._id.constructor.name, 'ObjectId')
73
+ assert.equal(received._id.toString(), '507f1f77bcf86cd799439011')
74
+ })
75
+
76
+ it('should wrap driver errors via getError', async () => {
77
+ const { instance } = createInstance()
78
+ instance.getCollection = mock.fn(() => ({
79
+ countDocuments: mock.fn(async () => { throw new Error('boom') })
80
+ }))
81
+ await assert.rejects(() => instance.count('courses', {}), e => e.code === 'MONGO_ERROR')
82
+ })
83
+ })
104
84
  })
@@ -0,0 +1,123 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { assertSafeQuery } from '../lib/utils/assertSafeQuery.js'
4
+
5
+ const errors = {
6
+ MONGO_BLOCKED_OPERATOR: {
7
+ setData (data) {
8
+ const e = new Error('MONGO_BLOCKED_OPERATOR')
9
+ e.data = data
10
+ return e
11
+ }
12
+ }
13
+ }
14
+
15
+ describe('assertSafeQuery()', () => {
16
+ it('should allow a simple field query', () => {
17
+ assert.doesNotThrow(() => assertSafeQuery({ name: 'test' }, errors))
18
+ })
19
+
20
+ it('should allow safe query operators', () => {
21
+ assert.doesNotThrow(() => assertSafeQuery({ age: { $gt: 18, $lt: 65 } }, errors))
22
+ })
23
+
24
+ it('should allow $or and $and operators', () => {
25
+ assert.doesNotThrow(() => assertSafeQuery({
26
+ $or: [{ name: 'a' }, { name: 'b' }],
27
+ $and: [{ active: true }]
28
+ }, errors))
29
+ })
30
+
31
+ it('should allow $regex operator', () => {
32
+ assert.doesNotThrow(() => assertSafeQuery({ name: { $regex: 'test', $options: 'i' } }, errors))
33
+ })
34
+
35
+ it('should allow $in and $nin operators', () => {
36
+ assert.doesNotThrow(() => assertSafeQuery({ status: { $in: ['active', 'pending'] } }, errors))
37
+ })
38
+
39
+ it('should allow $exists and $type operators', () => {
40
+ assert.doesNotThrow(() => assertSafeQuery({ field: { $exists: true, $type: 'string' } }, errors))
41
+ })
42
+
43
+ it('should throw MONGO_BLOCKED_OPERATOR for $where', () => {
44
+ assert.throws(
45
+ () => assertSafeQuery({ $where: 'this.a > this.b' }, errors),
46
+ (err) => {
47
+ assert.equal(err.message, 'MONGO_BLOCKED_OPERATOR')
48
+ assert.deepEqual(err.data, { operator: '$where' })
49
+ return true
50
+ }
51
+ )
52
+ })
53
+
54
+ it('should throw MONGO_BLOCKED_OPERATOR for $accumulator', () => {
55
+ assert.throws(
56
+ () => assertSafeQuery({ field: { $accumulator: { init: 'function() {}' } } }, errors),
57
+ (err) => {
58
+ assert.equal(err.message, 'MONGO_BLOCKED_OPERATOR')
59
+ assert.deepEqual(err.data, { operator: '$accumulator' })
60
+ return true
61
+ }
62
+ )
63
+ })
64
+
65
+ it('should throw MONGO_BLOCKED_OPERATOR for $function', () => {
66
+ assert.throws(
67
+ () => assertSafeQuery({ field: { $function: { body: 'function() {}' } } }, errors),
68
+ (err) => {
69
+ assert.equal(err.message, 'MONGO_BLOCKED_OPERATOR')
70
+ assert.deepEqual(err.data, { operator: '$function' })
71
+ return true
72
+ }
73
+ )
74
+ })
75
+
76
+ it('should reject blocked operators nested inside $or', () => {
77
+ assert.throws(
78
+ () => assertSafeQuery({ $or: [{ $where: 'true' }] }, errors),
79
+ (err) => {
80
+ assert.equal(err.message, 'MONGO_BLOCKED_OPERATOR')
81
+ return true
82
+ }
83
+ )
84
+ })
85
+
86
+ it('should reject blocked operators deeply nested', () => {
87
+ assert.throws(
88
+ () => assertSafeQuery({ a: { b: { $where: 'true' } } }, errors),
89
+ (err) => {
90
+ assert.equal(err.message, 'MONGO_BLOCKED_OPERATOR')
91
+ return true
92
+ }
93
+ )
94
+ })
95
+
96
+ it('should reject blocked operators inside $and within $or', () => {
97
+ assert.throws(
98
+ () => assertSafeQuery({ $or: [{ $and: [{ $where: 'true' }] }] }, errors),
99
+ (err) => {
100
+ assert.equal(err.message, 'MONGO_BLOCKED_OPERATOR')
101
+ return true
102
+ }
103
+ )
104
+ })
105
+
106
+ it('should handle null values gracefully', () => {
107
+ assert.doesNotThrow(() => assertSafeQuery({ field: null }, errors))
108
+ })
109
+
110
+ it('should handle undefined input gracefully', () => {
111
+ assert.doesNotThrow(() => assertSafeQuery(undefined, errors))
112
+ })
113
+
114
+ it('should handle empty object', () => {
115
+ assert.doesNotThrow(() => assertSafeQuery({}, errors))
116
+ })
117
+
118
+ it('should handle primitive values', () => {
119
+ assert.doesNotThrow(() => assertSafeQuery('string', errors))
120
+ assert.doesNotThrow(() => assertSafeQuery(42, errors))
121
+ assert.doesNotThrow(() => assertSafeQuery(true, errors))
122
+ })
123
+ })
@@ -0,0 +1,244 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { ObjectId } from 'mongodb'
4
+ import App from 'adapt-authoring-core/lib/App.js'
5
+
6
+ mock.getter(App, 'instance', () => ({
7
+ errors: {
8
+ INVALID_OBJECTID: {
9
+ setData (data) {
10
+ const e = new Error('INVALID_OBJECTID')
11
+ e.data = data
12
+ return e
13
+ }
14
+ },
15
+ MONGO_BLOCKED_OPERATOR: {
16
+ setData (data) {
17
+ const e = new Error('MONGO_BLOCKED_OPERATOR')
18
+ e.data = data
19
+ return e
20
+ }
21
+ }
22
+ }
23
+ }))
24
+
25
+ const { processParams } = await import('../lib/utils/processParams.js')
26
+
27
+ describe('processParams()', () => {
28
+ describe('query processing', () => {
29
+ it('should return a copy of the query', () => {
30
+ const query = { name: 'test' }
31
+ const result = processParams({ query })
32
+ assert.notEqual(result.query, query)
33
+ assert.deepEqual(result.query, query)
34
+ })
35
+
36
+ it('should not mutate the original query', () => {
37
+ const idStr = new ObjectId().toString()
38
+ const query = { _id: idStr }
39
+ processParams({ query })
40
+ assert.equal(query._id, idStr)
41
+ })
42
+
43
+ it('should convert ObjectIds in the query copy', () => {
44
+ const idStr = new ObjectId().toString()
45
+ const result = processParams({ query: { _id: idStr } })
46
+ assert.ok(result.query._id instanceof ObjectId)
47
+ })
48
+
49
+ it('should reject unsafe query operators', () => {
50
+ assert.throws(
51
+ () => processParams({ query: { $where: 'true' } }),
52
+ { message: 'MONGO_BLOCKED_OPERATOR' }
53
+ )
54
+ })
55
+ })
56
+
57
+ describe('data processing', () => {
58
+ it('should return a copy of the data', () => {
59
+ const data = { name: 'test' }
60
+ const result = processParams({ data })
61
+ assert.notEqual(result.data, data)
62
+ assert.deepEqual(result.data, { name: 'test' })
63
+ })
64
+
65
+ it('should not mutate the original data', () => {
66
+ const data = { _id: new ObjectId(), name: 'test' }
67
+ const originalId = data._id
68
+ processParams({ data })
69
+ assert.deepEqual(data._id, originalId)
70
+ })
71
+
72
+ it('should strip _id from the copy by default', () => {
73
+ const data = { _id: new ObjectId(), name: 'test' }
74
+ const result = processParams({ data })
75
+ assert.equal(result.data._id, undefined)
76
+ })
77
+
78
+ it('should strip _id from $set by default', () => {
79
+ const data = { $set: { _id: new ObjectId(), name: 'test' } }
80
+ const result = processParams({ data })
81
+ assert.equal(result.data.$set._id, undefined)
82
+ })
83
+
84
+ it('should preserve _id when preserveId option is set', () => {
85
+ const id = new ObjectId()
86
+ const result = processParams({ data: { _id: id, name: 'test' }, options: { preserveId: true } })
87
+ assert.deepEqual(result.data._id, id)
88
+ })
89
+
90
+ it('should convert ObjectIds in the data copy', () => {
91
+ const idStr = new ObjectId().toString()
92
+ const result = processParams({ data: { ref: idStr } })
93
+ assert.ok(result.data.ref instanceof ObjectId)
94
+ })
95
+ })
96
+
97
+ describe('options processing', () => {
98
+ it('should return a copy of the options', () => {
99
+ const options = { limit: 10 }
100
+ const result = processParams({ options })
101
+ assert.notEqual(result.options, options)
102
+ })
103
+
104
+ it('should not mutate the original options', () => {
105
+ const options = { limit: '10' }
106
+ processParams({ options })
107
+ assert.equal(options.limit, '10')
108
+ })
109
+
110
+ it('should parse limit as integer', () => {
111
+ const result = processParams({ options: { limit: '25' } })
112
+ assert.equal(result.options.limit, 25)
113
+ })
114
+
115
+ it('should parse skip as integer', () => {
116
+ const result = processParams({ options: { skip: '5' } })
117
+ assert.equal(result.options.skip, 5)
118
+ })
119
+
120
+ it('should strip preserveId from the returned options', () => {
121
+ const result = processParams({ data: { name: 'test' }, options: { preserveId: true, limit: 10 } })
122
+ assert.equal(result.options.preserveId, undefined)
123
+ })
124
+
125
+ it('should pass through allowed options unchanged', () => {
126
+ const result = processParams({ options: { sort: { name: 1 }, limit: 10 } })
127
+ assert.deepEqual(result.options.sort, { name: 1 })
128
+ })
129
+
130
+ it('should allow all safe driver options', () => {
131
+ const options = {
132
+ collation: { locale: 'en' },
133
+ includeResultMetadata: false,
134
+ limit: 10,
135
+ projection: { name: 1 },
136
+ returnCursor: true,
137
+ returnDocument: 'after',
138
+ skip: 5,
139
+ sort: { name: 1 },
140
+ upsert: true
141
+ }
142
+ const result = processParams({ options })
143
+ assert.deepEqual(result.options, { ...options, limit: 10, skip: 5 })
144
+ })
145
+
146
+ it('should strip disallowed options', () => {
147
+ const result = processParams({ options: { limit: 10, allowDiskUse: true, bypassDocumentValidation: true } })
148
+ assert.equal(result.options.limit, 10)
149
+ assert.equal(result.options.allowDiskUse, undefined)
150
+ assert.equal(result.options.bypassDocumentValidation, undefined)
151
+ })
152
+
153
+ it('should strip hint option', () => {
154
+ const result = processParams({ options: { hint: { _id: 1 } } })
155
+ assert.equal(result.options.hint, undefined)
156
+ })
157
+
158
+ it('should strip writeConcern option', () => {
159
+ const result = processParams({ options: { writeConcern: { w: 0 } } })
160
+ assert.equal(result.options.writeConcern, undefined)
161
+ })
162
+
163
+ it('should strip maxTimeMS option', () => {
164
+ const result = processParams({ options: { maxTimeMS: 1 } })
165
+ assert.equal(result.options.maxTimeMS, undefined)
166
+ })
167
+ })
168
+
169
+ describe('options value validation', () => {
170
+ it('should strip limit if not a valid number', () => {
171
+ const result = processParams({ options: { limit: 'abc' } })
172
+ assert.equal(result.options.limit, undefined)
173
+ })
174
+
175
+ it('should strip skip if not a valid number', () => {
176
+ const result = processParams({ options: { skip: 'abc' } })
177
+ assert.equal(result.options.skip, undefined)
178
+ })
179
+
180
+ it('should strip limit if NaN', () => {
181
+ const result = processParams({ options: { limit: NaN } })
182
+ assert.equal(result.options.limit, undefined)
183
+ })
184
+
185
+ it('should strip collation if not a plain object', () => {
186
+ const result = processParams({ options: { collation: 'en' } })
187
+ assert.equal(result.options.collation, undefined)
188
+ })
189
+
190
+ it('should strip collation if array', () => {
191
+ const result = processParams({ options: { collation: [{ locale: 'en' }] } })
192
+ assert.equal(result.options.collation, undefined)
193
+ })
194
+
195
+ it('should strip sort if not a plain object', () => {
196
+ const result = processParams({ options: { sort: 'name' } })
197
+ assert.equal(result.options.sort, undefined)
198
+ })
199
+
200
+ it('should strip projection if not a plain object', () => {
201
+ const result = processParams({ options: { projection: 'name' } })
202
+ assert.equal(result.options.projection, undefined)
203
+ })
204
+
205
+ it('should strip returnDocument if not before or after', () => {
206
+ const result = processParams({ options: { returnDocument: 'always' } })
207
+ assert.equal(result.options.returnDocument, undefined)
208
+ })
209
+
210
+ it('should allow returnDocument before', () => {
211
+ const result = processParams({ options: { returnDocument: 'before' } })
212
+ assert.equal(result.options.returnDocument, 'before')
213
+ })
214
+
215
+ it('should strip upsert if not boolean', () => {
216
+ const result = processParams({ options: { upsert: 'true' } })
217
+ assert.equal(result.options.upsert, undefined)
218
+ })
219
+
220
+ it('should strip includeResultMetadata if not boolean', () => {
221
+ const result = processParams({ options: { includeResultMetadata: 1 } })
222
+ assert.equal(result.options.includeResultMetadata, undefined)
223
+ })
224
+
225
+ it('should strip returnCursor if not boolean', () => {
226
+ const result = processParams({ options: { returnCursor: 1 } })
227
+ assert.equal(result.options.returnCursor, undefined)
228
+ })
229
+ })
230
+
231
+ describe('partial params', () => {
232
+ it('should handle query only', () => {
233
+ const result = processParams({ query: { name: 'test' } })
234
+ assert.deepEqual(result.query, { name: 'test' })
235
+ assert.equal(result.data, undefined)
236
+ assert.equal(result.options, undefined)
237
+ })
238
+
239
+ it('should handle empty call', () => {
240
+ const result = processParams()
241
+ assert.deepEqual(result, {})
242
+ })
243
+ })
244
+ })