adapt-authoring-mongodb 3.3.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,11 +120,10 @@ 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)
@@ -186,15 +158,9 @@ class MongoDBModule extends AbstractModule {
186
158
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#findOneAndUpdate
187
159
  */
188
160
  async update (collectionName, query, data, options) {
189
- const opts = Object.assign({ includeResultMetadata: false, returnDocument: 'after' }, options)
190
- this.parseOptions(opts)
191
- convertObjectIds(query)
192
- convertObjectIds(data)
193
- // MongoDB doesn't like the explicit setting of _id
194
- delete data._id
195
- if (data.$set) delete data.$set._id
161
+ const p = processParams({ query, data, options: { includeResultMetadata: false, returnDocument: 'after', ...options } })
196
162
  try {
197
- return await this.getCollection(collectionName).findOneAndUpdate(query, data, opts)
163
+ return await this.getCollection(collectionName).findOneAndUpdate(p.query, p.data, p.options)
198
164
  } catch (e) {
199
165
  this.log('error', `failed to update doc, ${e.message}`)
200
166
  throw this.getError(collectionName, 'update', e)
@@ -210,15 +176,10 @@ class MongoDBModule extends AbstractModule {
210
176
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#updateMany
211
177
  */
212
178
  async updateMany (collectionName, query, data, options) {
213
- this.parseOptions(options)
214
- convertObjectIds(query)
215
- convertObjectIds(data)
216
- // MongoDB doesn't like the explicit setting of _id
217
- delete data._id
218
- if (data.$set) delete data.$set._id
179
+ const p = processParams({ query, data, options })
219
180
  try {
220
- await this.getCollection(collectionName).updateMany(query, data, options)
221
- return this.find(collectionName, query)
181
+ await this.getCollection(collectionName).updateMany(p.query, p.data, p.options)
182
+ return this.find(collectionName, p.query)
222
183
  } catch (e) {
223
184
  this.log('error', `failed to update docs, ${e.message}`)
224
185
  throw this.getError(collectionName, 'update', e)
@@ -235,15 +196,9 @@ class MongoDBModule extends AbstractModule {
235
196
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#findOneAndReplace
236
197
  */
237
198
  async replace (collectionName, query, data, options) {
238
- const opts = Object.assign({ includeResultMetadata: false, returnDocument: 'after' }, options)
239
- convertObjectIds(query)
240
- convertObjectIds(data)
241
- this.parseOptions(options)
242
- // MongoDB doesn't like the explicit setting of _id
243
- delete data._id
244
- if (data.$set) delete data.$set._id
199
+ const p = processParams({ query, data, options: { includeResultMetadata: false, returnDocument: 'after', ...options } })
245
200
  try {
246
- return await this.getCollection(collectionName).findOneAndReplace(query, data, opts)
201
+ return await this.getCollection(collectionName).findOneAndReplace(p.query, p.data, p.options)
247
202
  } catch (e) {
248
203
  this.log('error', `failed to replace doc, ${e.message}`)
249
204
  throw this.getError(collectionName, 'replace', e)
@@ -259,10 +214,9 @@ class MongoDBModule extends AbstractModule {
259
214
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#deleteOne
260
215
  */
261
216
  async delete (collectionName, query, options) {
262
- convertObjectIds(query)
263
- this.parseOptions(options)
217
+ const p = processParams({ query, options })
264
218
  try {
265
- await this.getCollection(collectionName).deleteOne(query, options)
219
+ await this.getCollection(collectionName).deleteOne(p.query, p.options)
266
220
  } catch (e) {
267
221
  this.log('error', `failed to delete doc, ${e.message}`)
268
222
  throw this.getError(collectionName, 'delete', e)
@@ -278,10 +232,9 @@ class MongoDBModule extends AbstractModule {
278
232
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#deleteMany
279
233
  */
280
234
  async deleteMany (collectionName, query, options) {
281
- convertObjectIds(query)
282
- this.parseOptions(options)
235
+ const p = processParams({ query, options })
283
236
  try {
284
- await this.getCollection(collectionName).deleteMany(query, options)
237
+ await this.getCollection(collectionName).deleteMany(p.query, p.options)
285
238
  } catch (e) {
286
239
  this.log('error', `failed to delete docs, ${e.message}`)
287
240
  throw this.getError(collectionName, 'delete', e)
@@ -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.3.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()
@@ -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
+ })