@flowerforce/flowerbase 1.2.0 → 1.2.1-beta.11

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 (125) hide show
  1. package/README.md +28 -3
  2. package/dist/auth/controller.d.ts.map +1 -1
  3. package/dist/auth/controller.js +57 -3
  4. package/dist/auth/plugins/jwt.d.ts.map +1 -1
  5. package/dist/auth/plugins/jwt.js +49 -3
  6. package/dist/auth/providers/custom-function/controller.d.ts.map +1 -1
  7. package/dist/auth/providers/custom-function/controller.js +19 -3
  8. package/dist/auth/providers/local-userpass/controller.d.ts.map +1 -1
  9. package/dist/auth/providers/local-userpass/controller.js +125 -71
  10. package/dist/auth/providers/local-userpass/dtos.d.ts +11 -2
  11. package/dist/auth/providers/local-userpass/dtos.d.ts.map +1 -1
  12. package/dist/auth/utils.d.ts +53 -14
  13. package/dist/auth/utils.d.ts.map +1 -1
  14. package/dist/auth/utils.js +46 -63
  15. package/dist/constants.d.ts +14 -0
  16. package/dist/constants.d.ts.map +1 -1
  17. package/dist/constants.js +18 -5
  18. package/dist/features/functions/controller.d.ts.map +1 -1
  19. package/dist/features/functions/controller.js +32 -3
  20. package/dist/features/functions/dtos.d.ts +3 -0
  21. package/dist/features/functions/dtos.d.ts.map +1 -1
  22. package/dist/features/functions/interface.d.ts +3 -0
  23. package/dist/features/functions/interface.d.ts.map +1 -1
  24. package/dist/features/functions/utils.d.ts +2 -1
  25. package/dist/features/functions/utils.d.ts.map +1 -1
  26. package/dist/features/functions/utils.js +19 -7
  27. package/dist/features/rules/utils.d.ts.map +1 -1
  28. package/dist/features/rules/utils.js +11 -2
  29. package/dist/features/triggers/index.d.ts.map +1 -1
  30. package/dist/features/triggers/index.js +48 -7
  31. package/dist/features/triggers/utils.d.ts.map +1 -1
  32. package/dist/features/triggers/utils.js +118 -27
  33. package/dist/index.d.ts +8 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +57 -21
  36. package/dist/services/mongodb-atlas/index.d.ts.map +1 -1
  37. package/dist/services/mongodb-atlas/index.js +605 -478
  38. package/dist/services/mongodb-atlas/model.d.ts +2 -1
  39. package/dist/services/mongodb-atlas/model.d.ts.map +1 -1
  40. package/dist/services/mongodb-atlas/utils.d.ts +9 -2
  41. package/dist/services/mongodb-atlas/utils.d.ts.map +1 -1
  42. package/dist/services/mongodb-atlas/utils.js +113 -23
  43. package/dist/shared/handleUserRegistration.d.ts.map +1 -1
  44. package/dist/shared/handleUserRegistration.js +4 -1
  45. package/dist/shared/models/handleUserRegistration.model.d.ts +6 -2
  46. package/dist/shared/models/handleUserRegistration.model.d.ts.map +1 -1
  47. package/dist/utils/context/helpers.d.ts +7 -6
  48. package/dist/utils/context/helpers.d.ts.map +1 -1
  49. package/dist/utils/context/helpers.js +3 -0
  50. package/dist/utils/context/index.d.ts +1 -1
  51. package/dist/utils/context/index.d.ts.map +1 -1
  52. package/dist/utils/context/index.js +176 -5
  53. package/dist/utils/context/interface.d.ts +1 -1
  54. package/dist/utils/context/interface.d.ts.map +1 -1
  55. package/dist/utils/crypto/index.d.ts +1 -0
  56. package/dist/utils/crypto/index.d.ts.map +1 -1
  57. package/dist/utils/crypto/index.js +6 -2
  58. package/dist/utils/initializer/exposeRoutes.d.ts.map +1 -1
  59. package/dist/utils/initializer/exposeRoutes.js +11 -4
  60. package/dist/utils/initializer/registerPlugins.d.ts +3 -1
  61. package/dist/utils/initializer/registerPlugins.d.ts.map +1 -1
  62. package/dist/utils/initializer/registerPlugins.js +9 -6
  63. package/dist/utils/roles/helpers.js +11 -3
  64. package/dist/utils/roles/machines/commonValidators.d.ts.map +1 -1
  65. package/dist/utils/roles/machines/commonValidators.js +10 -6
  66. package/dist/utils/roles/machines/read/B/validators.d.ts +4 -0
  67. package/dist/utils/roles/machines/read/B/validators.d.ts.map +1 -0
  68. package/dist/utils/roles/machines/read/B/validators.js +8 -0
  69. package/dist/utils/roles/machines/read/C/index.d.ts.map +1 -1
  70. package/dist/utils/roles/machines/read/C/index.js +10 -7
  71. package/dist/utils/roles/machines/read/C/validators.d.ts +5 -0
  72. package/dist/utils/roles/machines/read/C/validators.d.ts.map +1 -0
  73. package/dist/utils/roles/machines/read/C/validators.js +29 -0
  74. package/dist/utils/roles/machines/read/D/index.d.ts.map +1 -1
  75. package/dist/utils/roles/machines/read/D/index.js +13 -11
  76. package/dist/utils/rules.d.ts +1 -1
  77. package/dist/utils/rules.d.ts.map +1 -1
  78. package/dist/utils/rules.js +26 -17
  79. package/jest.config.ts +2 -12
  80. package/jest.setup.ts +28 -0
  81. package/package.json +1 -2
  82. package/src/auth/controller.ts +70 -4
  83. package/src/auth/plugins/jwt.test.ts +93 -0
  84. package/src/auth/plugins/jwt.ts +62 -3
  85. package/src/auth/providers/custom-function/controller.ts +22 -5
  86. package/src/auth/providers/local-userpass/controller.ts +168 -96
  87. package/src/auth/providers/local-userpass/dtos.ts +13 -2
  88. package/src/auth/utils.ts +51 -86
  89. package/src/constants.ts +17 -3
  90. package/src/fastify.d.ts +32 -15
  91. package/src/features/functions/controller.ts +51 -3
  92. package/src/features/functions/dtos.ts +3 -0
  93. package/src/features/functions/interface.ts +3 -0
  94. package/src/features/functions/utils.ts +29 -8
  95. package/src/features/rules/utils.ts +11 -2
  96. package/src/features/triggers/index.ts +43 -1
  97. package/src/features/triggers/utils.ts +146 -38
  98. package/src/index.ts +69 -20
  99. package/src/services/mongodb-atlas/__tests__/findOneAndUpdate.test.ts +95 -0
  100. package/src/services/mongodb-atlas/__tests__/utils.test.ts +141 -0
  101. package/src/services/mongodb-atlas/index.ts +241 -90
  102. package/src/services/mongodb-atlas/model.ts +15 -2
  103. package/src/services/mongodb-atlas/utils.ts +158 -22
  104. package/src/shared/handleUserRegistration.ts +5 -4
  105. package/src/shared/models/handleUserRegistration.model.ts +8 -3
  106. package/src/types/fastify-raw-body.d.ts +22 -0
  107. package/src/utils/__tests__/STEP_B_STATES.test.ts +1 -1
  108. package/src/utils/__tests__/STEP_C_STATES.test.ts +1 -1
  109. package/src/utils/__tests__/STEP_D_STATES.test.ts +2 -2
  110. package/src/utils/__tests__/checkIsValidFieldNameFn.test.ts +9 -4
  111. package/src/utils/__tests__/registerPlugins.test.ts +16 -1
  112. package/src/utils/context/helpers.ts +3 -0
  113. package/src/utils/context/index.ts +238 -13
  114. package/src/utils/context/interface.ts +1 -1
  115. package/src/utils/crypto/index.ts +5 -1
  116. package/src/utils/initializer/exposeRoutes.ts +15 -8
  117. package/src/utils/initializer/registerPlugins.ts +15 -7
  118. package/src/utils/roles/helpers.ts +23 -5
  119. package/src/utils/roles/machines/commonValidators.ts +10 -5
  120. package/src/utils/roles/machines/read/B/validators.ts +8 -0
  121. package/src/utils/roles/machines/read/C/index.ts +11 -7
  122. package/src/utils/roles/machines/read/C/validators.ts +21 -0
  123. package/src/utils/roles/machines/read/D/index.ts +22 -12
  124. package/src/utils/rules.ts +31 -22
  125. package/tsconfig.spec.json +7 -0
@@ -7,6 +7,41 @@ import { readJsonContent } from '../../utils'
7
7
  import { GenerateContext } from '../../utils/context'
8
8
  import { HandlerParams, Trigger, Triggers } from './interface'
9
9
 
10
+ const registerOnClose = (
11
+ app: HandlerParams['app'],
12
+ handler: () => Promise<void> | void,
13
+ label: string
14
+ ) => {
15
+ if (app.server) {
16
+ app.server.once('close', () => {
17
+ Promise.resolve(handler()).catch((error) => {
18
+ console.error(`${label} close error`, error)
19
+ })
20
+ })
21
+ return
22
+ }
23
+
24
+ try {
25
+ app.addHook('onClose', async () => {
26
+ try {
27
+ await handler()
28
+ } catch (error) {
29
+ console.error(`${label} close error`, error)
30
+ }
31
+ })
32
+ } catch (error) {
33
+ console.error(`${label} hook registration error`, error)
34
+ }
35
+ }
36
+
37
+ const shouldIgnoreStreamError = (error: unknown) => {
38
+ const err = error as { name?: string; message?: string }
39
+ if (err?.name === 'MongoClientClosedError') return true
40
+ if (err?.message?.includes('client was closed')) return true
41
+ if (err?.message?.includes('Client is closed')) return true
42
+ return false
43
+ }
44
+
10
45
  /**
11
46
  * Loads trigger files from the specified directory and returns them as an array of objects.
12
47
  * Each object contains the file name and the parsed JSON content.
@@ -54,7 +89,7 @@ const handleCronTrigger = async ({
54
89
  services,
55
90
  app
56
91
  }: HandlerParams) => {
57
- cron.schedule(config.schedule, async () => {
92
+ const task = cron.schedule(config.schedule, async () => {
58
93
  await GenerateContext({
59
94
  args: [],
60
95
  app,
@@ -65,6 +100,7 @@ const handleCronTrigger = async ({
65
100
  services
66
101
  })
67
102
  })
103
+ registerOnClose(app, () => task.stop(), 'Scheduled trigger')
68
104
  }
69
105
 
70
106
  const handleAuthenticationTrigger = async ({
@@ -75,51 +111,113 @@ const handleAuthenticationTrigger = async ({
75
111
  app
76
112
  }: HandlerParams) => {
77
113
  const { database } = config
114
+ const authCollection = AUTH_CONFIG.authCollection ?? 'auth_users'
115
+ const collection = app.mongo.client.db(database || DB_NAME).collection(authCollection)
78
116
  const pipeline = [
79
117
  {
80
118
  $match: {
81
- operationType: { $in: ['insert'] }
119
+ operationType: { $in: ['insert', 'update', 'replace'] }
82
120
  }
83
121
  }
84
122
  ]
85
- const changeStream = app.mongo.client
86
- .db(database || DB_NAME)
87
- .collection(AUTH_CONFIG.authCollection)
88
- .watch(pipeline, {
89
- fullDocument: 'whenAvailable'
90
- })
123
+ const changeStream = collection.watch(pipeline, {
124
+ fullDocument: 'whenAvailable'
125
+ })
126
+ changeStream.on('error', (error) => {
127
+ if (shouldIgnoreStreamError(error)) return
128
+ console.error('Authentication trigger change stream error', error)
129
+ })
91
130
  changeStream.on('change', async function (change) {
92
- const document = change['fullDocument' as keyof typeof change] as Record<
93
- string,
94
- string
95
- > //TODO -> define user type
96
-
97
- if (document) {
98
- delete document.password
99
-
100
- const currentUser = { ...document }
101
- delete currentUser.password
102
- await GenerateContext({
103
- args: [{
104
- user: {
105
- ...currentUser,
106
- id: currentUser._id.toString(),
107
- data: {
108
- _id: currentUser._id.toString(),
109
- email: currentUser.email
110
- }
111
- }
112
- }],
113
- app,
114
- rules: StateManager.select("rules"),
115
- user: {}, // TODO from currentUser ??
116
- currentFunction: triggerHandler,
117
- functionsList,
118
- services,
119
- runAsSystem: true
120
- })
131
+ const operationType = change['operationType' as keyof typeof change] as string | undefined
132
+ const documentKey = change['documentKey' as keyof typeof change] as
133
+ | { _id?: unknown }
134
+ | undefined
135
+ const fullDocument = change['fullDocument' as keyof typeof change] as
136
+ | Record<string, unknown>
137
+ | null
138
+ if (!documentKey?._id) {
139
+ return
140
+ }
141
+
142
+ const updateDescription = change[
143
+ 'updateDescription' as keyof typeof change
144
+ ] as { updatedFields?: Record<string, unknown> } | undefined
145
+ const updatedStatus = updateDescription?.updatedFields?.status
146
+ let confirmedCandidate = false
147
+ let confirmedDocument =
148
+ fullDocument as Record<string, unknown> | null
149
+
150
+ if (operationType === 'update') {
151
+ if (updatedStatus === 'confirmed') {
152
+ confirmedCandidate = true
153
+ } else if (updatedStatus === undefined) {
154
+ const fetched = await collection.findOne({
155
+ _id: documentKey._id
156
+ }) as Record<string, unknown> | null
157
+ confirmedDocument = fetched ?? confirmedDocument
158
+ confirmedCandidate = (confirmedDocument as { status?: string } | null)?.status === 'confirmed'
159
+ }
160
+ } else {
161
+ confirmedCandidate = (confirmedDocument as { status?: string } | null)?.status === 'confirmed'
162
+ }
163
+
164
+ if (!confirmedCandidate) {
165
+ return
166
+ }
167
+
168
+ const updateResult = await collection.findOneAndUpdate(
169
+ {
170
+ _id: documentKey._id,
171
+ status: 'confirmed',
172
+ on_user_creation_triggered_at: { $exists: false }
173
+ },
174
+ {
175
+ $set: {
176
+ on_user_creation_triggered_at: new Date()
177
+ }
178
+ },
179
+ {
180
+ returnDocument: 'after'
181
+ }
182
+ )
183
+
184
+ const document =
185
+ (updateResult?.value as Record<string, unknown> | null) ?? confirmedDocument
186
+ if (!document) {
187
+ return
121
188
  }
189
+
190
+ delete (document as { password?: unknown }).password
191
+
192
+ const currentUser = { ...document }
193
+ delete (currentUser as { password?: unknown }).password
194
+ await GenerateContext({
195
+ args: [{
196
+ user: {
197
+ ...currentUser,
198
+ id: (currentUser as { _id: { toString: () => string } })._id.toString(),
199
+ data: {
200
+ _id: (currentUser as { _id: { toString: () => string } })._id.toString(),
201
+ email: (currentUser as { email?: string }).email
202
+ }
203
+ }
204
+ }],
205
+ app,
206
+ rules: StateManager.select("rules"),
207
+ user: {}, // TODO from currentUser ??
208
+ currentFunction: triggerHandler,
209
+ functionsList,
210
+ services,
211
+ runAsSystem: true
212
+ })
122
213
  })
214
+ registerOnClose(
215
+ app,
216
+ async () => {
217
+ await changeStream.close()
218
+ },
219
+ 'Authentication trigger'
220
+ )
123
221
  }
124
222
 
125
223
  /**
@@ -175,6 +273,10 @@ const handleDataBaseTrigger = async ({
175
273
  ? 'whenAvailable'
176
274
  : undefined
177
275
  })
276
+ changeStream.on('error', (error) => {
277
+ if (shouldIgnoreStreamError(error)) return
278
+ console.error('Database trigger change stream error', error)
279
+ })
178
280
  changeStream.on('change', async function ({ clusterTime, ...change }) {
179
281
  await GenerateContext({
180
282
  args: [change],
@@ -186,7 +288,13 @@ const handleDataBaseTrigger = async ({
186
288
  services
187
289
  })
188
290
  })
189
- // TODO -> gestire close dello stream
291
+ registerOnClose(
292
+ app,
293
+ async () => {
294
+ await changeStream.close()
295
+ },
296
+ 'Database trigger'
297
+ )
190
298
  }
191
299
 
192
300
  export const TRIGGER_HANDLERS = {
package/src/index.ts CHANGED
@@ -14,12 +14,22 @@ import { exposeRoutes } from './utils/initializer/exposeRoutes'
14
14
  import { registerPlugins } from './utils/initializer/registerPlugins'
15
15
  export * from './model'
16
16
 
17
+
18
+ export type ALLOWED_METHODS = "GET" | "POST" | "PUT" | "DELETE"
19
+
20
+ export type CorsConfig = {
21
+ origin: string
22
+ methods: ALLOWED_METHODS[]
23
+ }
24
+
17
25
  export type InitializeConfig = {
18
26
  projectId: string
19
27
  mongodbUrl?: string
20
28
  jwtSecret?: string
21
29
  port?: number
22
30
  host?: string
31
+ corsConfig?: CorsConfig
32
+ basePath?: string
23
33
  }
24
34
 
25
35
  /**
@@ -35,26 +45,38 @@ export async function initialize({
35
45
  host = DEFAULT_CONFIG.HOST,
36
46
  jwtSecret = DEFAULT_CONFIG.JWT_SECRET,
37
47
  port = DEFAULT_CONFIG.PORT,
38
- mongodbUrl = DEFAULT_CONFIG.MONGODB_URL
48
+ mongodbUrl = DEFAULT_CONFIG.MONGODB_URL,
49
+ corsConfig = DEFAULT_CONFIG.CORS_OPTIONS,
50
+ basePath
39
51
  }: InitializeConfig) {
52
+ if (!jwtSecret || jwtSecret.trim().length === 0) {
53
+ throw new Error('JWT secret missing: set JWT_SECRET or pass jwtSecret to initialize()')
54
+ }
55
+
56
+ const resolvedBasePath = basePath ?? require.main?.path ?? process.cwd()
40
57
  const fastify = Fastify({
41
58
  logger: !!DEFAULT_CONFIG.ENABLE_LOGGER
42
59
  })
43
60
 
44
- const basePath = require.main?.path
45
- console.log("BASE PATH", basePath)
61
+ const isTest = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined
62
+ const logInfo = (...args: unknown[]) => {
63
+ if (!isTest) {
64
+ console.log(...args)
65
+ }
66
+ }
46
67
 
47
- console.log("CURRENT PORT", port)
48
- console.log("CURRENT HOST", host)
68
+ logInfo("BASE PATH", resolvedBasePath)
69
+ logInfo("CURRENT PORT", port)
70
+ logInfo("CURRENT HOST", host)
49
71
 
50
- const functionsList = await loadFunctions(basePath)
51
- console.log("Functions LOADED")
52
- const triggersList = await loadTriggers(basePath)
53
- console.log("Triggers LOADED")
54
- const endpointsList = await loadEndpoints(basePath)
55
- console.log("Endpoints LOADED")
56
- const rulesList = await loadRules(basePath)
57
- console.log("Rules LOADED")
72
+ const functionsList = await loadFunctions(resolvedBasePath)
73
+ logInfo("Functions LOADED")
74
+ const triggersList = await loadTriggers(resolvedBasePath)
75
+ logInfo("Triggers LOADED")
76
+ const endpointsList = await loadEndpoints(resolvedBasePath)
77
+ logInfo("Endpoints LOADED")
78
+ const rulesList = await loadRules(resolvedBasePath)
79
+ logInfo("Rules LOADED")
58
80
 
59
81
  const stateConfig = {
60
82
  functions: functionsList,
@@ -78,7 +100,33 @@ export async function initialize({
78
100
  deepLinking: false
79
101
  },
80
102
  uiHooks: {
81
- onRequest: function (request, reply, next) { next() },
103
+ onRequest: function (request, reply, next) {
104
+ const swaggerUser = DEFAULT_CONFIG.SWAGGER_UI_USER
105
+ const swaggerPassword = DEFAULT_CONFIG.SWAGGER_UI_PASSWORD
106
+ if (!swaggerUser && !swaggerPassword) {
107
+ next()
108
+ return
109
+ }
110
+ const authHeader = request.headers.authorization
111
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
112
+ reply
113
+ .code(401)
114
+ .header('WWW-Authenticate', 'Basic realm="Swagger UI"')
115
+ .send({ message: 'Unauthorized' })
116
+ return
117
+ }
118
+ const encoded = authHeader.slice('Basic '.length)
119
+ const decoded = Buffer.from(encoded, 'base64').toString('utf8')
120
+ const [user, pass] = decoded.split(':')
121
+ if (user !== swaggerUser || pass !== swaggerPassword) {
122
+ reply
123
+ .code(401)
124
+ .header('WWW-Authenticate', 'Basic realm="Swagger UI"')
125
+ .send({ message: 'Unauthorized' })
126
+ return
127
+ }
128
+ next()
129
+ },
82
130
  preHandler: function (request, reply, next) { next() }
83
131
  },
84
132
  staticCSP: true,
@@ -91,18 +139,19 @@ export async function initialize({
91
139
  register: fastify.register,
92
140
  mongodbUrl,
93
141
  jwtSecret,
94
- functionsList
142
+ functionsList,
143
+ corsConfig
95
144
  })
96
145
 
97
- console.log('Plugins registration COMPLETED')
146
+ logInfo('Plugins registration COMPLETED')
98
147
  await exposeRoutes(fastify)
99
- console.log('APP Routes registration COMPLETED')
148
+ logInfo('APP Routes registration COMPLETED')
100
149
  await registerFunctions({ app: fastify, functionsList, rulesList })
101
- console.log('Functions registration COMPLETED')
150
+ logInfo('Functions registration COMPLETED')
102
151
  await generateEndpoints({ app: fastify, functionsList, endpointsList, rulesList })
103
- console.log('HTTP Endpoints registration COMPLETED')
152
+ logInfo('HTTP Endpoints registration COMPLETED')
104
153
  fastify.ready(() => {
105
- console.log("FASTIFY IS READY")
154
+ logInfo("FASTIFY IS READY")
106
155
  if (triggersList?.length > 0) activateTriggers({ fastify, triggersList, functionsList })
107
156
  })
108
157
  await fastify.listen({ port, host })
@@ -0,0 +1,95 @@
1
+ import { Document, ObjectId } from 'mongodb'
2
+ import MongoDbAtlas from '..'
3
+ import { Role, Rules } from '../../../features/rules/interface'
4
+
5
+ const createAppWithCollection = (collection: Record<string, unknown>) => ({
6
+ mongo: {
7
+ client: {
8
+ db: jest.fn().mockReturnValue({
9
+ collection: jest.fn().mockReturnValue(collection)
10
+ })
11
+ }
12
+ }
13
+ })
14
+
15
+ const createRules = (roleOverrides: Partial<Role> = {}): Rules => ({
16
+ todos: {
17
+ database: 'db',
18
+ collection: 'todos',
19
+ filters: [],
20
+ roles: [
21
+ {
22
+ name: 'owner',
23
+ apply_when: {},
24
+ insert: true,
25
+ delete: true,
26
+ search: true,
27
+ read: true,
28
+ write: true,
29
+ ...roleOverrides
30
+ }
31
+ ]
32
+ }
33
+ })
34
+
35
+ describe('mongodb-atlas findOneAndUpdate', () => {
36
+ it('applies write/read validation and returns the updated document', async () => {
37
+ const id = new ObjectId()
38
+ const existingDoc = { _id: id, title: 'Old', userId: 'user-1' }
39
+ const updatedDoc = { _id: id, title: 'New', userId: 'user-1' }
40
+ const findOne = jest.fn().mockResolvedValue(existingDoc)
41
+ const aggregate = jest.fn().mockReturnValue({
42
+ toArray: jest.fn().mockResolvedValue([updatedDoc])
43
+ })
44
+ const findOneAndUpdate = jest.fn().mockResolvedValue(updatedDoc)
45
+ const collection = {
46
+ collectionName: 'todos',
47
+ findOne,
48
+ aggregate,
49
+ findOneAndUpdate
50
+ }
51
+
52
+ const app = createAppWithCollection(collection)
53
+ const operators = MongoDbAtlas(app as any, {
54
+ rules: createRules(),
55
+ user: { id: 'user-1' }
56
+ })
57
+ .db('db')
58
+ .collection('todos')
59
+
60
+ const result = await operators.findOneAndUpdate({ _id: id }, { $set: { title: 'New' } })
61
+
62
+ expect(findOne).toHaveBeenCalled()
63
+ expect(aggregate).toHaveBeenCalled()
64
+ expect(findOneAndUpdate).toHaveBeenCalledWith(
65
+ { $and: [{ _id: id }] },
66
+ { $set: { title: 'New' } }
67
+ )
68
+ expect(result).toEqual(updatedDoc)
69
+ })
70
+
71
+ it('rejects updates when write permission is denied', async () => {
72
+ const id = new ObjectId()
73
+ const existingDoc = { _id: id, title: 'Old', userId: 'user-1' }
74
+ const findOne = jest.fn().mockResolvedValue(existingDoc)
75
+ const findOneAndUpdate = jest.fn()
76
+ const collection = {
77
+ collectionName: 'todos',
78
+ findOne,
79
+ findOneAndUpdate
80
+ }
81
+
82
+ const app = createAppWithCollection(collection)
83
+ const operators = MongoDbAtlas(app as any, {
84
+ rules: createRules({ write: false }),
85
+ user: { id: 'user-1' }
86
+ })
87
+ .db('db')
88
+ .collection('todos')
89
+
90
+ await expect(
91
+ operators.findOneAndUpdate({ _id: id }, { title: 'Denied' } as Document)
92
+ ).rejects.toThrow('Update not permitted')
93
+ expect(findOneAndUpdate).not.toHaveBeenCalled()
94
+ })
95
+ })
@@ -0,0 +1,141 @@
1
+ import { ensureClientPipelineStages, getHiddenFieldsFromRulesConfig, prependUnsetStage, applyAccessControlToPipeline } from '../utils'
2
+ import { Role } from '../../../utils/roles/interface'
3
+
4
+ describe('MongoDB Atlas aggregate helpers', () => {
5
+ describe('ensureClientPipelineStages', () => {
6
+ it('allows safe stages', () => {
7
+ expect(() =>
8
+ ensureClientPipelineStages([{ $match: { active: true } }])
9
+ ).not.toThrow()
10
+ })
11
+
12
+ it('throws when unsupported stage is used', () => {
13
+ expect(() =>
14
+ ensureClientPipelineStages([{ $replaceRoot: { newRoot: '$$ROOT' } }])
15
+ ).toThrow('Stage $replaceRoot is not allowed in client aggregate pipelines')
16
+ })
17
+
18
+ it('recurses into nested lookups and facets without throwing', () => {
19
+ const pipeline = [
20
+ {
21
+ $lookup: {
22
+ from: 'other',
23
+ localField: 'ref',
24
+ foreignField: '_id',
25
+ as: 'joined',
26
+ pipeline: [
27
+ {
28
+ $facet: {
29
+ safe: [{ $match: { foo: 'bar' } }]
30
+ }
31
+ }
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+
37
+ expect(() => ensureClientPipelineStages(pipeline)).not.toThrow()
38
+ })
39
+ })
40
+
41
+ describe('getHiddenFieldsFromRulesConfig', () => {
42
+ it('returns fields marked as unreadable', () => {
43
+ const roles: Role[] = [
44
+ {
45
+ name: 'demo',
46
+ apply_when: {},
47
+ insert: true,
48
+ delete: true,
49
+ search: true,
50
+ read: true,
51
+ write: true,
52
+ fields: {
53
+ secret: { read: false, write: false },
54
+ visible: { read: true, write: false }
55
+ },
56
+ additional_fields: {
57
+ hiddenExtra: { read: false, write: false }
58
+ }
59
+ }
60
+ ]
61
+
62
+ const hidden = getHiddenFieldsFromRulesConfig({
63
+ roles
64
+ })
65
+
66
+ expect(hidden).toEqual(expect.arrayContaining(['secret', 'hiddenExtra']))
67
+ expect(hidden).not.toContain('visible')
68
+ })
69
+ })
70
+
71
+ describe('prependUnsetStage', () => {
72
+ it('inserts an $unset stage when hidden fields are present', () => {
73
+ const pipeline = [{ $match: { active: true } }]
74
+ const result = prependUnsetStage(pipeline, ['password', 'secret'])
75
+
76
+ expect(result[0]).toEqual({ $unset: ['password', 'secret'] })
77
+ expect(result[1]).toEqual(pipeline[0])
78
+ })
79
+
80
+ it('returns original pipeline if no hidden fields exist', () => {
81
+ const pipeline = [{ $match: { active: true } }]
82
+ expect(prependUnsetStage(pipeline, [])).toEqual(pipeline)
83
+ })
84
+ })
85
+
86
+ describe('applyAccessControlToPipeline', () => {
87
+ it('prepends hidden-field $unset inside lookup pipelines for client requests', () => {
88
+ const rules = {
89
+ main: {
90
+ filters: [],
91
+ roles: []
92
+ },
93
+ other: {
94
+ filters: [],
95
+ roles: [
96
+ {
97
+ name: 'lookup-role',
98
+ apply_when: {},
99
+ insert: true,
100
+ delete: true,
101
+ search: true,
102
+ read: true,
103
+ write: true,
104
+ fields: {
105
+ secretField: { read: false, write: false }
106
+ },
107
+ additional_fields: {
108
+ secretAux: { read: false, write: false }
109
+ }
110
+ }
111
+ ]
112
+ }
113
+ }
114
+
115
+ const pipeline = [
116
+ {
117
+ $lookup: {
118
+ from: 'other',
119
+ localField: 'ref',
120
+ foreignField: '_id',
121
+ as: 'joined',
122
+ pipeline: [{ $match: { active: true } }]
123
+ }
124
+ }
125
+ ]
126
+
127
+ const sanitized = applyAccessControlToPipeline(
128
+ pipeline,
129
+ rules,
130
+ {},
131
+ 'main',
132
+ { isClientPipeline: true }
133
+ )
134
+
135
+ const lookupPipeline = sanitized[0].$lookup.pipeline
136
+ expect(lookupPipeline?.[0]).toEqual({
137
+ $unset: ['secretField', 'secretAux']
138
+ })
139
+ })
140
+ })
141
+ })