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.
- package/errors/errors.json +7 -0
- package/lib/MongoDBModule.js +17 -64
- package/lib/utils/assertSafeQuery.js +31 -0
- package/lib/utils/processParams.js +71 -0
- package/lib/utils.js +4 -1
- package/package.json +1 -1
- package/tests/MongoDBModule.spec.js +1 -43
- package/tests/utils-assertSafeQuery.spec.js +123 -0
- package/tests/utils-processParams.spec.js +244 -0
package/errors/errors.json
CHANGED
|
@@ -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"
|
package/lib/MongoDBModule.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { AbstractModule } from 'adapt-authoring-core'
|
|
2
2
|
import { MongoClient } from 'mongodb'
|
|
3
|
-
import {
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
8
|
+
export { processParams } from './utils/processParams.js'
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
+
})
|