@flowerforce/flowerbase 1.7.5-beta.2 → 1.7.5-beta.4

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.
Files changed (63) hide show
  1. package/dist/features/functions/controller.d.ts +2 -0
  2. package/dist/features/functions/controller.d.ts.map +1 -1
  3. package/dist/features/functions/controller.js +244 -19
  4. package/dist/services/api/index.d.ts +4 -0
  5. package/dist/services/api/index.d.ts.map +1 -1
  6. package/dist/services/api/utils.d.ts +1 -0
  7. package/dist/services/api/utils.d.ts.map +1 -1
  8. package/dist/services/index.d.ts +4 -0
  9. package/dist/services/index.d.ts.map +1 -1
  10. package/dist/services/mongodb-atlas/utils.d.ts.map +1 -1
  11. package/dist/services/mongodb-atlas/utils.js +17 -1
  12. package/dist/utils/context/helpers.d.ts +12 -0
  13. package/dist/utils/context/helpers.d.ts.map +1 -1
  14. package/dist/utils/roles/helpers.d.ts.map +1 -1
  15. package/dist/utils/roles/helpers.js +19 -4
  16. package/dist/utils/roles/interface.d.ts +10 -6
  17. package/dist/utils/roles/interface.d.ts.map +1 -1
  18. package/dist/utils/roles/machines/commonValidators.js +2 -2
  19. package/dist/utils/roles/machines/fieldPermissions.d.ts +8 -0
  20. package/dist/utils/roles/machines/fieldPermissions.d.ts.map +1 -0
  21. package/dist/utils/roles/machines/fieldPermissions.js +67 -0
  22. package/dist/utils/roles/machines/read/A/index.d.ts.map +1 -1
  23. package/dist/utils/roles/machines/read/A/index.js +4 -3
  24. package/dist/utils/roles/machines/read/C/index.d.ts.map +1 -1
  25. package/dist/utils/roles/machines/read/C/index.js +16 -16
  26. package/dist/utils/roles/machines/read/C/validators.js +2 -2
  27. package/dist/utils/roles/machines/read/D/index.js +1 -1
  28. package/dist/utils/roles/machines/read/D/validators.d.ts +1 -1
  29. package/dist/utils/roles/machines/read/D/validators.d.ts.map +1 -1
  30. package/dist/utils/roles/machines/read/D/validators.js +19 -21
  31. package/dist/utils/roles/machines/write/B/index.d.ts.map +1 -1
  32. package/dist/utils/roles/machines/write/B/index.js +12 -9
  33. package/dist/utils/roles/machines/write/C/index.js +1 -1
  34. package/dist/utils/roles/machines/write/C/validators.d.ts +1 -1
  35. package/dist/utils/roles/machines/write/C/validators.d.ts.map +1 -1
  36. package/dist/utils/roles/machines/write/C/validators.js +16 -21
  37. package/package.json +1 -1
  38. package/src/features/functions/__tests__/watch-filter.test.ts +116 -0
  39. package/src/features/functions/controller.ts +282 -22
  40. package/src/features/triggers/__tests__/index.test.ts +2 -2
  41. package/src/services/mongodb-atlas/__tests__/findOneAndUpdate.test.ts +1 -1
  42. package/src/services/mongodb-atlas/utils.ts +19 -4
  43. package/src/utils/__tests__/STEP_A_STATES.test.ts +24 -2
  44. package/src/utils/__tests__/STEP_C_STATES.test.ts +61 -27
  45. package/src/utils/__tests__/STEP_D_STATES.test.ts +9 -9
  46. package/src/utils/__tests__/WRITE_STEP_B_STATES.test.ts +184 -0
  47. package/src/utils/__tests__/checkAdditionalFieldsFn.test.ts +2 -2
  48. package/src/utils/__tests__/checkFieldsPropertyExists.test.ts +13 -0
  49. package/src/utils/__tests__/checkIsValidFieldNameFn.test.ts +52 -121
  50. package/src/utils/__tests__/evaluateTopLevelReadFn.test.ts +10 -1
  51. package/src/utils/__tests__/evaluateTopLevelWriteFn.test.ts +21 -5
  52. package/src/utils/roles/helpers.ts +18 -4
  53. package/src/utils/roles/interface.ts +13 -6
  54. package/src/utils/roles/machines/commonValidators.ts +1 -1
  55. package/src/utils/roles/machines/fieldPermissions.ts +86 -0
  56. package/src/utils/roles/machines/read/A/index.ts +4 -3
  57. package/src/utils/roles/machines/read/C/index.ts +18 -18
  58. package/src/utils/roles/machines/read/C/validators.ts +2 -2
  59. package/src/utils/roles/machines/read/D/index.ts +1 -1
  60. package/src/utils/roles/machines/read/D/validators.ts +12 -25
  61. package/src/utils/roles/machines/write/B/index.ts +12 -9
  62. package/src/utils/roles/machines/write/C/index.ts +1 -1
  63. package/src/utils/roles/machines/write/C/validators.ts +9 -26
@@ -54,11 +54,29 @@ const serializeEjson = (value: unknown) =>
54
54
  const isRecord = (value: unknown): value is Record<string, unknown> =>
55
55
  !!value && typeof value === 'object' && !Array.isArray(value)
56
56
 
57
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
58
+ if (!isRecord(value)) return false
59
+ const prototype = Object.getPrototypeOf(value)
60
+ return prototype === Object.prototype || prototype === null
61
+ }
62
+
63
+ const isCursorLike = (
64
+ value: unknown
65
+ ): value is { toArray: () => Promise<unknown> | unknown } => {
66
+ if (!value || typeof value !== 'object') return false
67
+ return typeof (value as { toArray?: unknown }).toArray === 'function'
68
+ }
69
+
70
+ const normalizeFunctionResult = async (value: unknown) => {
71
+ if (!isCursorLike(value)) return value
72
+ return await value.toArray()
73
+ }
74
+
57
75
  type WatchSubscriber = {
58
76
  id: string
59
77
  user: Record<string, any>
60
78
  response: ServerResponse
61
- extraFilter?: Document
79
+ documentFilter?: Document
62
80
  }
63
81
 
64
82
  type SharedWatchStream = {
@@ -74,13 +92,221 @@ type SharedWatchStream = {
74
92
 
75
93
  const sharedWatchStreams = new Map<string, SharedWatchStream>()
76
94
  let watchSubscriberCounter = 0
95
+ const maxSharedWatchStreams = Number(process.env.MAX_SHARED_WATCH_STREAMS || 200)
96
+ const debugWatchStreams = process.env.DEBUG_FUNCTIONS === 'true'
97
+
98
+ const changeEventRootKeys = new Set([
99
+ '_id',
100
+ 'operationType',
101
+ 'clusterTime',
102
+ 'txnNumber',
103
+ 'lsid',
104
+ 'ns',
105
+ 'documentKey',
106
+ 'fullDocument',
107
+ 'updateDescription'
108
+ ])
109
+
110
+ const isChangeEventPath = (key: string) => {
111
+ if (changeEventRootKeys.has(key)) return true
112
+ return (
113
+ key.startsWith('ns.') ||
114
+ key.startsWith('documentKey.') ||
115
+ key.startsWith('fullDocument.') ||
116
+ key.startsWith('updateDescription.')
117
+ )
118
+ }
119
+
120
+ const isOpaqueChangeEventObjectKey = (key: string) =>
121
+ key === 'ns' || key === 'documentKey' || key === 'fullDocument' || key === 'updateDescription'
122
+
123
+ export const mapWatchFilterToChangeStreamMatch = (value: unknown): unknown => {
124
+ if (Array.isArray(value)) {
125
+ return value.map((item) => mapWatchFilterToChangeStreamMatch(item))
126
+ }
127
+
128
+ if (!isPlainRecord(value)) return value
129
+
130
+ return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
131
+ if (key.startsWith('$')) {
132
+ acc[key] = mapWatchFilterToChangeStreamMatch(current)
133
+ return acc
134
+ }
135
+
136
+ if (isOpaqueChangeEventObjectKey(key)) {
137
+ acc[key] = current
138
+ return acc
139
+ }
140
+
141
+ if (isChangeEventPath(key)) {
142
+ acc[key] = mapWatchFilterToChangeStreamMatch(current)
143
+ return acc
144
+ }
145
+
146
+ acc[`fullDocument.${key}`] = mapWatchFilterToChangeStreamMatch(current)
147
+ return acc
148
+ }, {})
149
+ }
150
+
151
+ const isLogicalOperator = (key: string) => key === '$and' || key === '$or' || key === '$nor'
152
+
153
+ export const mapWatchFilterToDocumentQuery = (value: unknown): unknown => {
154
+ if (Array.isArray(value)) {
155
+ const mapped = value
156
+ .map((item) => mapWatchFilterToDocumentQuery(item))
157
+ .filter((item) => !(isRecord(item) && Object.keys(item).length === 0))
158
+ return mapped
159
+ }
160
+
161
+ if (!isPlainRecord(value)) return value
162
+
163
+ return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
164
+ if (key.startsWith('$')) {
165
+ const mapped = mapWatchFilterToDocumentQuery(current)
166
+ if (isLogicalOperator(key) && Array.isArray(mapped)) {
167
+ if (mapped.length > 0) {
168
+ acc[key] = mapped
169
+ }
170
+ return acc
171
+ }
172
+ if (typeof mapped !== 'undefined') {
173
+ acc[key] = mapped
174
+ }
175
+ return acc
176
+ }
177
+
178
+ if (key === 'fullDocument') {
179
+ if (!isPlainRecord(current)) return acc
180
+ const mapped = mapWatchFilterToDocumentQuery(current)
181
+ if (isRecord(mapped)) {
182
+ Object.assign(acc, mapped)
183
+ }
184
+ return acc
185
+ }
186
+
187
+ if (key.startsWith('fullDocument.')) {
188
+ const docKey = key.slice('fullDocument.'.length)
189
+ if (!docKey) return acc
190
+ acc[docKey] = mapWatchFilterToDocumentQuery(current)
191
+ return acc
192
+ }
193
+
194
+ if (isChangeEventPath(key)) {
195
+ return acc
196
+ }
197
+
198
+ acc[key] = mapWatchFilterToDocumentQuery(current)
199
+ return acc
200
+ }, {})
201
+ }
202
+
203
+ const toStableValue = (value: unknown): unknown => {
204
+ if (Array.isArray(value)) {
205
+ return value.map((item) => toStableValue(item))
206
+ }
207
+ if (!isPlainRecord(value)) return value
208
+
209
+ const sortedEntries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
210
+ return sortedEntries.reduce<Record<string, unknown>>((acc, [key, current]) => {
211
+ acc[key] = toStableValue(current)
212
+ return acc
213
+ }, {})
214
+ }
215
+
216
+ const stableSerialize = (value: unknown) => {
217
+ const serialized = EJSON.serialize(value, { relaxed: false })
218
+ return JSON.stringify(toStableValue(serialized))
219
+ }
220
+
221
+ const getWatchPermissionContext = (user: Record<string, any>) => ({
222
+ role: user.role,
223
+ roles: user.roles,
224
+ data: user.data,
225
+ custom_data: user.custom_data,
226
+ user_data: user.user_data
227
+ })
228
+
229
+ const resolveWatchStream = (
230
+ database: string,
231
+ collection: string,
232
+ watchArgs: Record<string, unknown>,
233
+ user: Record<string, any>
234
+ ) => {
235
+ const keys = Object.keys(watchArgs)
236
+ const hasOnlyAllowedKeys = keys.every((key) => key === 'filter' || key === 'ids')
237
+ if (!hasOnlyAllowedKeys) {
238
+ throw new Error('watch options support only "filter" or "ids"')
239
+ }
240
+
241
+ const extraFilter = parseWatchFilter(watchArgs)
242
+ const ids = watchArgs.ids
243
+ if (extraFilter && typeof ids !== 'undefined') {
244
+ throw new Error('watch options cannot include both "ids" and "filter"')
245
+ }
246
+
247
+ const pipeline: Document[] = []
248
+ if (extraFilter) {
249
+ pipeline.push({ $match: mapWatchFilterToChangeStreamMatch(extraFilter) as Document })
250
+ }
251
+
252
+ if (typeof ids !== 'undefined') {
253
+ if (!Array.isArray(ids)) {
254
+ throw new Error('watch ids must be an array')
255
+ }
256
+ pipeline.push({
257
+ $match: {
258
+ $or: [
259
+ { 'documentKey._id': { $in: ids } },
260
+ { 'fullDocument._id': { $in: ids } }
261
+ ]
262
+ }
263
+ })
264
+ }
265
+
266
+ const options = { fullDocument: 'updateLookup' }
267
+ const streamKey = stableSerialize({
268
+ database,
269
+ collection,
270
+ pipeline,
271
+ options,
272
+ permissionContext: getWatchPermissionContext(user)
273
+ })
274
+
275
+ return { extraFilter, options, pipeline, streamKey }
276
+ }
277
+
278
+ const getWatchStats = () => {
279
+ let subscribers = 0
280
+ for (const hub of sharedWatchStreams.values()) {
281
+ subscribers += hub.subscribers.size
282
+ }
283
+ return {
284
+ hubs: sharedWatchStreams.size,
285
+ subscribers
286
+ }
287
+ }
288
+
289
+ const logWatchStats = (
290
+ event: string,
291
+ details?: Record<string, unknown>
292
+ ) => {
293
+ if (!debugWatchStreams) return
294
+ const stats = getWatchStats()
295
+ console.log('[watch-pool]', event, {
296
+ hubs: stats.hubs,
297
+ subscribers: stats.subscribers,
298
+ ...details
299
+ })
300
+ }
77
301
 
78
302
  const parseWatchFilter = (args: unknown): Document | undefined => {
79
303
  if (!isRecord(args)) return undefined
80
- const candidate =
81
- (isRecord(args.filter) ? args.filter : undefined) ??
82
- (isRecord(args.query) ? args.query : undefined)
83
- return candidate ? (candidate as Document) : undefined
304
+ const candidate = isRecord(args.filter) ? args.filter : undefined
305
+ if (!candidate) return undefined
306
+ if ('$match' in candidate) {
307
+ throw new Error('watch filter must be a query object, not a $match stage')
308
+ }
309
+ return candidate as Document
84
310
  }
85
311
 
86
312
  const isReadableDocumentResult = (value: unknown) =>
@@ -177,12 +403,13 @@ export const functionsController: FunctionController = async (
177
403
  functionsList,
178
404
  services
179
405
  })
180
- if (isReturnedError(result)) {
406
+ const normalizedResult = await normalizeFunctionResult(result)
407
+ if (isReturnedError(normalizedResult)) {
181
408
  res.type('application/json')
182
- return JSON.stringify({ message: result.message, name: result.name })
409
+ return JSON.stringify({ message: normalizedResult.message, name: normalizedResult.name })
183
410
  }
184
411
  res.type('application/json')
185
- return serializeEjson(result)
412
+ return serializeEjson(normalizedResult)
186
413
  } catch (error) {
187
414
  res.status(400)
188
415
  res.type('application/json')
@@ -211,7 +438,9 @@ export const functionsController: FunctionController = async (
211
438
  )
212
439
  const config = EJSON.deserialize(decodedConfig) as Base64Function
213
440
 
214
- const [{ database, collection, ...watchArgs }] = config.arguments
441
+ const [{ database, collection, ...watchArgsInput }] = config.arguments
442
+ const watchArgs = isRecord(watchArgsInput) ? watchArgsInput : {}
443
+ console.log("🚀 ~ functionsController ~ watchArgs:", watchArgs)
215
444
 
216
445
  const headers = {
217
446
  'Content-Type': 'text/event-stream',
@@ -222,21 +451,33 @@ export const functionsController: FunctionController = async (
222
451
  "access-control-allow-headers": "X-Stitch-Location, X-Baas-Location, Location",
223
452
  };
224
453
 
225
- res.raw.writeHead(200, headers)
226
- res.raw.flushHeaders();
227
-
228
- const streamKey = `${database}::${collection}`
229
454
  const subscriberId = `${Date.now()}-${watchSubscriberCounter++}`
230
- const extraFilter = parseWatchFilter(watchArgs)
231
- const mongoClient = app.mongo.client as unknown as {
232
- db: (name: string) => { collection: (name: string) => { watch: (...args: any[]) => any } }
233
- }
455
+ const {
456
+ streamKey,
457
+ extraFilter,
458
+ options: watchOptions,
459
+ pipeline: watchPipeline
460
+ } = resolveWatchStream(database, collection, watchArgs, user)
234
461
 
235
462
  let hub = sharedWatchStreams.get(streamKey)
236
463
  if (!hub) {
237
- const stream = mongoClient.db(database).collection(collection).watch([], {
238
- fullDocument: 'whenAvailable'
464
+ if (sharedWatchStreams.size >= maxSharedWatchStreams) {
465
+ res.status(503)
466
+ return JSON.stringify({
467
+ error: JSON.stringify({
468
+ message: 'Watch stream limit reached',
469
+ name: 'WatchStreamLimitError'
470
+ }),
471
+ error_code: 'WatchStreamLimitError'
472
+ })
473
+ }
474
+ const stream = services['mongodb-atlas'](app, {
475
+ user,
476
+ rules
239
477
  })
478
+ .db(database)
479
+ .collection(collection)
480
+ .watch(watchPipeline, watchOptions)
240
481
  hub = {
241
482
  database,
242
483
  collection,
@@ -244,8 +485,14 @@ export const functionsController: FunctionController = async (
244
485
  subscribers: new Map<string, WatchSubscriber>()
245
486
  }
246
487
  sharedWatchStreams.set(streamKey, hub)
488
+ logWatchStats('hub-created', { streamKey, database, collection })
489
+ } else {
490
+ logWatchStats('hub-reused', { streamKey, database, collection })
247
491
  }
248
492
 
493
+ res.raw.writeHead(200, headers)
494
+ res.raw.flushHeaders();
495
+
249
496
  const ensureHubListeners = (currentHub: SharedWatchStream) => {
250
497
  if ((currentHub as SharedWatchStream & { listenersBound?: boolean }).listenersBound) {
251
498
  return
@@ -255,6 +502,7 @@ export const functionsController: FunctionController = async (
255
502
  currentHub.stream.off('change', onHubChange)
256
503
  currentHub.stream.off('error', onHubError)
257
504
  sharedWatchStreams.delete(streamKey)
505
+ logWatchStats('hub-closed', { streamKey, database, collection })
258
506
  try {
259
507
  await currentHub.stream.close()
260
508
  } catch {
@@ -263,11 +511,13 @@ export const functionsController: FunctionController = async (
263
511
  }
264
512
 
265
513
  const onHubChange = async (change: Document) => {
514
+ console.log("🚀 ~ onHubChange ~ change:", change)
266
515
  const subscribers = Array.from(currentHub.subscribers.values())
267
516
  await Promise.all(subscribers.map(async (subscriber) => {
268
517
  const subscriberRes = subscriber.response
269
518
  if (subscriberRes.writableEnded || subscriberRes.destroyed) {
270
519
  currentHub.subscribers.delete(subscriber.id)
520
+ logWatchStats('subscriber-auto-removed', { streamKey, subscriberId: subscriber.id })
271
521
  return
272
522
  }
273
523
 
@@ -276,8 +526,8 @@ export const functionsController: FunctionController = async (
276
526
  (change as { fullDocument?: { _id?: unknown } })?.fullDocument?._id
277
527
  if (typeof docId === 'undefined') return
278
528
 
279
- const readQuery = subscriber.extraFilter
280
- ? ({ $and: [subscriber.extraFilter, { _id: docId }] } as Document)
529
+ const readQuery = subscriber.documentFilter
530
+ ? ({ $and: [subscriber.documentFilter, { _id: docId }] } as Document)
281
531
  : ({ _id: docId } as Document)
282
532
 
283
533
  try {
@@ -288,6 +538,7 @@ export const functionsController: FunctionController = async (
288
538
  .db(currentHub.database)
289
539
  .collection(currentHub.collection)
290
540
  .findOne(readQuery)
541
+ console.log("🚀 ~ onHubChange ~ readableDoc:", readableDoc)
291
542
 
292
543
  if (!isReadableDocumentResult(readableDoc)) return
293
544
  subscriberRes.write(`data: ${serializeEjson(change)}\n\n`)
@@ -295,6 +546,7 @@ export const functionsController: FunctionController = async (
295
546
  subscriberRes.write(`event: error\ndata: ${formatFunctionExecutionError(error)}\n\n`)
296
547
  subscriberRes.end()
297
548
  currentHub.subscribers.delete(subscriber.id)
549
+ logWatchStats('subscriber-error-removed', { streamKey, subscriberId: subscriber.id })
298
550
  }
299
551
  }))
300
552
 
@@ -326,17 +578,25 @@ export const functionsController: FunctionController = async (
326
578
  id: subscriberId,
327
579
  user,
328
580
  response: res.raw,
329
- extraFilter
581
+ documentFilter: (() => {
582
+ if (!extraFilter) return undefined
583
+ const mapped = mapWatchFilterToDocumentQuery(extraFilter)
584
+ if (!isRecord(mapped) || Object.keys(mapped).length === 0) return undefined
585
+ return mapped as Document
586
+ })()
330
587
  }
331
588
  hub.subscribers.set(subscriberId, subscriber)
589
+ logWatchStats('subscriber-added', { streamKey, subscriberId })
332
590
 
333
591
  req.raw.on('close', () => {
334
592
  const currentHub = sharedWatchStreams.get(streamKey)
335
593
  if (!currentHub) return
336
594
  currentHub.subscribers.delete(subscriberId)
595
+ logWatchStats('subscriber-closed', { streamKey, subscriberId })
337
596
  if (!currentHub.subscribers.size) {
338
597
  void currentHub.stream.close()
339
598
  sharedWatchStreams.delete(streamKey)
599
+ logWatchStats('hub-empty-closed', { streamKey })
340
600
  }
341
601
  })
342
602
  })
@@ -21,8 +21,8 @@ describe('activateTriggers', () => {
21
21
 
22
22
  it('skips triggers marked as disabled', async () => {
23
23
  const functionsList = {
24
- runEnabled: jest.fn(),
25
- runDisabled: jest.fn()
24
+ runEnabled: { code: 'exports = async () => {}' },
25
+ runDisabled: { code: 'exports = async () => {}' }
26
26
  }
27
27
 
28
28
  await activateTriggers({
@@ -171,7 +171,7 @@ describe('mongodb-atlas findOneAndUpdate', () => {
171
171
 
172
172
  const app = createAppWithCollection(collection)
173
173
  const operators = MongoDbAtlas(app as any, {
174
- rules: createRules({ insert: false }),
174
+ rules: createRules({ write: undefined, insert: false }),
175
175
  user: { id: 'user-1' }
176
176
  })
177
177
  .db('db')
@@ -323,9 +323,10 @@ export function getHiddenFieldsFromRulesConfig(rulesConfig?: { roles?: Role[] })
323
323
  function collectHiddenFieldsFromRoles(roles: Role[] = []) {
324
324
  const hiddenFields = new Set<string>()
325
325
 
326
- const collectFromFields = (
327
- fields?: Role['fields'] | Role['additional_fields']
328
- ) => {
326
+ const isFieldPermissionObject = (value: unknown): value is { read?: unknown; write?: unknown } =>
327
+ !!value && typeof value === 'object' && ('read' in value || 'write' in value)
328
+
329
+ const collectFromFields = (fields?: Role['fields']) => {
329
330
  if (!fields) return
330
331
  Object.entries(fields).forEach(([fieldName, permissions]) => {
331
332
  const canRead = Boolean(permissions?.read || permissions?.write)
@@ -335,9 +336,23 @@ function collectHiddenFieldsFromRoles(roles: Role[] = []) {
335
336
  })
336
337
  }
337
338
 
339
+ const collectFromAdditionalFields = (fields?: Role['additional_fields']) => {
340
+ if (!fields || typeof fields !== 'object') return
341
+ // Global additional_fields permissions (read/write) apply to unknown fields and cannot be mapped.
342
+ if (isFieldPermissionObject(fields)) return
343
+
344
+ Object.entries(fields).forEach(([fieldName, permissions]) => {
345
+ if (!isFieldPermissionObject(permissions)) return
346
+ const canRead = Boolean(permissions.read || permissions.write)
347
+ if (!canRead) {
348
+ hiddenFields.add(fieldName)
349
+ }
350
+ })
351
+ }
352
+
338
353
  roles.forEach((role) => {
339
354
  collectFromFields(role.fields)
340
- collectFromFields(role.additional_fields)
355
+ collectFromAdditionalFields(role.additional_fields)
341
356
  })
342
357
 
343
358
  return Array.from(hiddenFields)
@@ -1,8 +1,13 @@
1
+ import { evaluateTopLevelPermissionsFn } from '../roles/machines/commonValidators'
1
2
  import { MachineContext } from '../roles/machines/interface'
2
3
  import { STEP_A_STATES } from '../roles/machines/read/A'
3
4
  import * as Utils from '../roles/machines/utils'
4
5
  const { evaluateSearch, checkSearchRequest } = STEP_A_STATES
5
6
 
7
+ jest.mock('../roles/machines/commonValidators', () => ({
8
+ evaluateTopLevelPermissionsFn: jest.fn()
9
+ }))
10
+
6
11
  const endValidation = jest.fn()
7
12
  const goToNextValidationStage = jest.fn()
8
13
  const next = jest.fn()
@@ -57,16 +62,33 @@ describe('STEP_A_STATES', () => {
57
62
  })
58
63
  expect(goToNextValidationStage).toHaveBeenCalled()
59
64
  })
60
- it('evaluateSearch should end a failed validation ', () => {
65
+ it('evaluateSearch should go to next validation stage when search is allowed', async () => {
66
+ (evaluateTopLevelPermissionsFn as jest.Mock).mockResolvedValueOnce(true)
67
+ const mockContext = {
68
+ role: {
69
+ name: 'test'
70
+ }
71
+ } as MachineContext
72
+ await evaluateSearch({
73
+ endValidation,
74
+ context: mockContext,
75
+ goToNextValidationStage,
76
+ next,
77
+ initialStep: null
78
+ })
79
+ expect(goToNextValidationStage).toHaveBeenCalled()
80
+ })
81
+ it('evaluateSearch should end a failed validation when search is denied', async () => {
61
82
  const mockedLogInfo = jest
62
83
  .spyOn(Utils, 'logMachineInfo')
63
84
  .mockImplementation(() => 'Mocked Value')
85
+ ; (evaluateTopLevelPermissionsFn as jest.Mock).mockResolvedValueOnce(false)
64
86
  const mockContext = {
65
87
  role: {
66
88
  name: 'test'
67
89
  }
68
90
  } as MachineContext
69
- evaluateSearch({
91
+ await evaluateSearch({
70
92
  endValidation,
71
93
  context: mockContext,
72
94
  goToNextValidationStage,
@@ -22,7 +22,7 @@ describe('STEP_C_STATES', () => {
22
22
  beforeEach(() => {
23
23
  jest.clearAllMocks()
24
24
  })
25
- it('evaluateTopLevelRead should go to evaluateTopLevelWrite if evaluateTopLevelReadFn returns false ', async () => {
25
+ it('evaluateTopLevelRead should pass readCheck=false to evaluateTopLevelWrite', async () => {
26
26
  const mockedLogInfo = jest
27
27
  .spyOn(Utils, 'logMachineInfo')
28
28
  .mockImplementation(() => 'Mocked Value')
@@ -35,7 +35,7 @@ describe('STEP_C_STATES', () => {
35
35
  next,
36
36
  initialStep: null
37
37
  })
38
- expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { check: false })
38
+ expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { readCheck: false })
39
39
  expect(mockedLogInfo).toHaveBeenCalledWith({
40
40
  enabled: mockContext.enableLog,
41
41
  machine: 'C',
@@ -44,7 +44,7 @@ describe('STEP_C_STATES', () => {
44
44
  })
45
45
  mockedLogInfo.mockRestore()
46
46
  })
47
- it('evaluateTopLevelRead should go to evaluateTopLevelWrite if evaluateTopLevelReadFn returns undefined ', async () => {
47
+ it('evaluateTopLevelRead should pass readCheck=undefined to evaluateTopLevelWrite', async () => {
48
48
  (evaluateTopLevelReadFn as jest.Mock).mockReturnValueOnce(undefined)
49
49
  const mockContext = {} as MachineContext
50
50
  await evaluateTopLevelRead({
@@ -54,9 +54,9 @@ describe('STEP_C_STATES', () => {
54
54
  next,
55
55
  initialStep: null
56
56
  })
57
- expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { check: undefined })
57
+ expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { readCheck: undefined })
58
58
  })
59
- it('evaluateTopLevelRead should endValidation validation if evaluateTopLevelReadFn returns true ', async () => {
59
+ it('evaluateTopLevelRead should pass readCheck=true to evaluateTopLevelWrite', async () => {
60
60
  (evaluateTopLevelReadFn as jest.Mock).mockReturnValueOnce(true)
61
61
  const mockContext = {} as MachineContext
62
62
  await evaluateTopLevelRead({
@@ -66,13 +66,12 @@ describe('STEP_C_STATES', () => {
66
66
  next,
67
67
  initialStep: null
68
68
  })
69
- expect(endValidation).toHaveBeenCalledWith({ success: true })
69
+ expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { readCheck: true })
70
70
  })
71
- it('checkFieldsProperty should go to next validation stage if checkFieldsPropertyExists returns true with initialStep checkIsValidFieldName', async () => {
71
+ it('checkFieldsProperty should go to next validation stage with initialStep checkIsValidFieldName', async () => {
72
72
  const mockedLogInfo = jest
73
73
  .spyOn(Utils, 'logMachineInfo')
74
74
  .mockImplementation(() => 'Mocked Value')
75
- ;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(true)
76
75
  const mockContext = {} as MachineContext
77
76
  await checkFieldsProperty({
78
77
  endValidation,
@@ -90,24 +89,13 @@ describe('STEP_C_STATES', () => {
90
89
  })
91
90
  mockedLogInfo.mockRestore()
92
91
  })
93
- it('checkFieldsProperty should go to next validation stage if checkFieldsPropertyExists returns fslse with initialStep checkAdditionalFields', async () => {
94
- (checkFieldsPropertyExists as jest.Mock).mockReturnValue(false)
95
- const mockContext = {} as MachineContext
96
- await checkFieldsProperty({
97
- endValidation,
98
- context: mockContext,
99
- goToNextValidationStage,
100
- next,
101
- initialStep: null
102
- })
103
- expect(goToNextValidationStage).toHaveBeenCalledWith('checkAdditionalFields')
104
- })
105
- it('evaluateTopLevelWrite should end a success validation if evaluateTopLevelWriteFn returns true', async () => {
92
+ it('evaluateTopLevelWrite should end a success validation when read is true', async () => {
106
93
  const mockedLogInfo = jest
107
94
  .spyOn(Utils, 'logMachineInfo')
108
95
  .mockImplementation(() => 'Mocked Value')
109
- ;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(true)
110
- const mockContext = {} as MachineContext
96
+ ;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(false)
97
+ ;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(false)
98
+ const mockContext = { prevParams: { readCheck: true } } as unknown as MachineContext
111
99
  await evaluateTopLevelWrite({
112
100
  endValidation,
113
101
  context: mockContext,
@@ -124,11 +112,23 @@ describe('STEP_C_STATES', () => {
124
112
  })
125
113
  mockedLogInfo.mockRestore()
126
114
  })
127
- it('evaluateTopLevelWrite should end a failed validation if evaluateTopLevelWriteFn returns false and prevParams.check is false', async () => {
115
+ it('evaluateTopLevelWrite should end a success validation when read is false and write is true', async () => {
116
+ ;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(true)
117
+ const mockContext = { prevParams: { readCheck: false } } as unknown as MachineContext
118
+ await evaluateTopLevelWrite({
119
+ endValidation,
120
+ context: mockContext,
121
+ goToNextValidationStage,
122
+ next,
123
+ initialStep: null
124
+ })
125
+ expect(endValidation).toHaveBeenCalledWith({ success: true })
126
+ })
127
+ it('evaluateTopLevelWrite should end a failed validation when read is false and write is not true', async () => {
128
128
  (evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(false)
129
129
  const mockContext = {
130
130
  prevParams: {
131
- check: false
131
+ readCheck: false
132
132
  }
133
133
  } as unknown as MachineContext
134
134
  await evaluateTopLevelWrite({
@@ -140,11 +140,28 @@ describe('STEP_C_STATES', () => {
140
140
  })
141
141
  expect(endValidation).toHaveBeenCalledWith({ success: false })
142
142
  })
143
- it('evaluateTopLevelWrite should go to next step checkFieldsProperty if evaluateTopLevelWriteFn returns false and prevParams.check is not false ', async () => {
143
+ it('evaluateTopLevelWrite should end a success validation when read is undefined and write is true', async () => {
144
+ ;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(true)
145
+ const mockContext = {
146
+ prevParams: {
147
+ readCheck: undefined
148
+ }
149
+ } as unknown as MachineContext
150
+ await evaluateTopLevelWrite({
151
+ endValidation,
152
+ context: mockContext,
153
+ goToNextValidationStage,
154
+ next,
155
+ initialStep: null
156
+ })
157
+ expect(endValidation).toHaveBeenCalledWith({ success: true })
158
+ })
159
+ it('evaluateTopLevelWrite should go to checkFieldsProperty when read and write are undefined/false but fields exist', async () => {
144
160
  (evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(false)
161
+ ;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(true)
145
162
  const mockContext = {
146
163
  prevParams: {
147
- check: true
164
+ readCheck: undefined
148
165
  }
149
166
  } as unknown as MachineContext
150
167
  await evaluateTopLevelWrite({
@@ -156,4 +173,21 @@ describe('STEP_C_STATES', () => {
156
173
  })
157
174
  expect(next).toHaveBeenCalledWith('checkFieldsProperty')
158
175
  })
176
+ it('evaluateTopLevelWrite should end a failed validation when read and write are undefined/false and no field rules exist', async () => {
177
+ ;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(undefined)
178
+ ;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(false)
179
+ const mockContext = {
180
+ prevParams: {
181
+ readCheck: undefined
182
+ }
183
+ } as unknown as MachineContext
184
+ await evaluateTopLevelWrite({
185
+ endValidation,
186
+ context: mockContext,
187
+ goToNextValidationStage,
188
+ next,
189
+ initialStep: null
190
+ })
191
+ expect(endValidation).toHaveBeenCalledWith({ success: false })
192
+ })
159
193
  })