@budibase/worker 2.5.8 → 2.5.10-alpha.0
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/package.json +9 -8
- package/scripts/dev/manage.js +1 -0
- package/src/api/controllers/global/users.ts +7 -4
- package/src/api/controllers/system/accounts.ts +2 -2
- package/src/api/index.ts +1 -1
- package/src/api/routes/global/configs.ts +9 -1
- package/src/api/routes/global/tests/scim.spec.ts +1029 -0
- package/src/api/routes/global/tests/users.spec.ts +10 -4
- package/src/api/routes/index.ts +4 -6
- package/src/constants/templates/core.hbs +2 -1
- package/src/environment.ts +0 -1
- package/src/index.ts +7 -7
- package/src/initPro.ts +13 -0
- package/src/middleware/handleScimBody.ts +12 -0
- package/src/sdk/accounts/metadata.ts +11 -2
- package/src/sdk/users/users.ts +101 -135
- package/src/tests/TestConfiguration.ts +50 -1
- package/src/tests/api/index.ts +7 -0
- package/src/tests/api/scim/groups.ts +101 -0
- package/src/tests/api/scim/shared.ts +44 -0
- package/src/tests/api/scim/users.ts +94 -0
- package/src/tests/jestEnv.ts +0 -1
- package/src/tests/jestSetup.ts +0 -2
- package/src/utilities/email.ts +2 -2
- package/tsconfig.json +3 -8
- package/src/elasticApm.ts +0 -10
- package/src/tests/logging.ts +0 -34
- package/src/utilities/appService.ts +0 -46
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
import tk from "timekeeper"
|
|
2
|
+
import _ from "lodash"
|
|
3
|
+
import { mocks, structures } from "@budibase/backend-core/tests"
|
|
4
|
+
import {
|
|
5
|
+
ScimCreateUserRequest,
|
|
6
|
+
ScimGroupResponse,
|
|
7
|
+
ScimUpdateRequest,
|
|
8
|
+
ScimUserResponse,
|
|
9
|
+
} from "@budibase/types"
|
|
10
|
+
import { TestConfiguration } from "../../../../tests"
|
|
11
|
+
import { events } from "@budibase/backend-core"
|
|
12
|
+
|
|
13
|
+
mocks.licenses.useScimIntegration()
|
|
14
|
+
|
|
15
|
+
describe("scim", () => {
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
jest.resetAllMocks()
|
|
18
|
+
tk.freeze(mocks.date.MOCK_DATE)
|
|
19
|
+
mocks.licenses.useScimIntegration()
|
|
20
|
+
|
|
21
|
+
await config.setSCIMConfig(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const config = new TestConfiguration()
|
|
25
|
+
|
|
26
|
+
const unauthorisedTests = (fn: (...params: any) => Promise<any>) => {
|
|
27
|
+
describe("unauthorised calls", () => {
|
|
28
|
+
it("unauthorised calls are not allowed", async () => {
|
|
29
|
+
const response = await fn(...Array(fn.length - 1).fill({}), {
|
|
30
|
+
setHeaders: false,
|
|
31
|
+
expect: 403,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
expect(response).toEqual({ message: "Tenant id not set", status: 403 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("cannot be called when feature is disabled", async () => {
|
|
38
|
+
mocks.licenses.useCloudFree()
|
|
39
|
+
const response = await fn(...Array(fn.length - 1).fill({}), {
|
|
40
|
+
expect: 400,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(response).toEqual({
|
|
44
|
+
error: {
|
|
45
|
+
code: "feature_disabled",
|
|
46
|
+
featureName: "scim",
|
|
47
|
+
},
|
|
48
|
+
message: "scim is not currently enabled",
|
|
49
|
+
status: 400,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("cannot be called when feature is enabled but the config disabled", async () => {
|
|
54
|
+
await config.setSCIMConfig(false)
|
|
55
|
+
const response = await fn(...Array(fn.length - 1).fill({}), {
|
|
56
|
+
expect: 400,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(response).toEqual({
|
|
60
|
+
error: {
|
|
61
|
+
code: "feature_disabled",
|
|
62
|
+
featureName: "scim",
|
|
63
|
+
},
|
|
64
|
+
message: "scim is not currently enabled",
|
|
65
|
+
status: 400,
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
await config.beforeAll()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await config.afterAll()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("/api/global/scim/v2/users", () => {
|
|
80
|
+
describe("GET /api/global/scim/v2/users", () => {
|
|
81
|
+
const getScimUsers = config.api.scimUsersAPI.get
|
|
82
|
+
|
|
83
|
+
unauthorisedTests(getScimUsers)
|
|
84
|
+
|
|
85
|
+
describe("no users exist", () => {
|
|
86
|
+
it("should retrieve empty list", async () => {
|
|
87
|
+
const response = await getScimUsers()
|
|
88
|
+
|
|
89
|
+
expect(response).toEqual({
|
|
90
|
+
Resources: [],
|
|
91
|
+
itemsPerPage: 20,
|
|
92
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
93
|
+
startIndex: 1,
|
|
94
|
+
totalResults: 0,
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("multiple users exist", () => {
|
|
100
|
+
const userCount = 30
|
|
101
|
+
let users: ScimUserResponse[]
|
|
102
|
+
|
|
103
|
+
beforeAll(async () => {
|
|
104
|
+
users = []
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < userCount; i++) {
|
|
107
|
+
const body = structures.scim.createUserRequest()
|
|
108
|
+
users.push(await config.api.scimUsersAPI.post({ body }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
users = users.sort((a, b) => (a.id > b.id ? 1 : -1))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("fetches full first page", async () => {
|
|
115
|
+
const response = await getScimUsers()
|
|
116
|
+
|
|
117
|
+
expect(response).toEqual({
|
|
118
|
+
Resources: expect.arrayContaining(users.slice(0, 20)),
|
|
119
|
+
itemsPerPage: 20,
|
|
120
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
121
|
+
startIndex: 1,
|
|
122
|
+
totalResults: userCount,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("fetches second page", async () => {
|
|
127
|
+
const response = await getScimUsers({ params: { startIndex: 20 } })
|
|
128
|
+
|
|
129
|
+
expect(response).toEqual({
|
|
130
|
+
Resources: users.slice(20),
|
|
131
|
+
itemsPerPage: 20,
|
|
132
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
133
|
+
startIndex: 21,
|
|
134
|
+
totalResults: userCount,
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("can filter by user name", async () => {
|
|
139
|
+
const userToFetch = _.sample(users)
|
|
140
|
+
|
|
141
|
+
const response = await getScimUsers({
|
|
142
|
+
params: {
|
|
143
|
+
filter: encodeURI(`userName eq "${userToFetch?.userName}"`),
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
expect(response).toEqual({
|
|
148
|
+
Resources: [userToFetch],
|
|
149
|
+
itemsPerPage: 20,
|
|
150
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
151
|
+
startIndex: 1,
|
|
152
|
+
totalResults: 1,
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("can filter by external id", async () => {
|
|
157
|
+
const userToFetch = _.sample(users)
|
|
158
|
+
|
|
159
|
+
const response = await getScimUsers({
|
|
160
|
+
params: {
|
|
161
|
+
filter: encodeURI(`externalId eq "${userToFetch?.externalId}"`),
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
expect(response).toEqual({
|
|
166
|
+
Resources: [userToFetch],
|
|
167
|
+
itemsPerPage: 20,
|
|
168
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
169
|
+
startIndex: 1,
|
|
170
|
+
totalResults: 1,
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("can filter by email", async () => {
|
|
175
|
+
const userToFetch = _.sample(users)
|
|
176
|
+
|
|
177
|
+
const response = await getScimUsers({
|
|
178
|
+
params: {
|
|
179
|
+
filter: encodeURI(
|
|
180
|
+
`emails[type eq "work"].value eq "${
|
|
181
|
+
userToFetch?.emails![0].value
|
|
182
|
+
}"`
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(response).toEqual({
|
|
188
|
+
Resources: [userToFetch],
|
|
189
|
+
itemsPerPage: 20,
|
|
190
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
191
|
+
startIndex: 1,
|
|
192
|
+
totalResults: 1,
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe("POST /api/global/scim/v2/users", () => {
|
|
199
|
+
const postScimUser = config.api.scimUsersAPI.post
|
|
200
|
+
|
|
201
|
+
beforeAll(async () => {
|
|
202
|
+
await config.useNewTenant()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
unauthorisedTests(postScimUser)
|
|
206
|
+
|
|
207
|
+
describe("no users exist", () => {
|
|
208
|
+
it("a new user can be created and persisted", async () => {
|
|
209
|
+
const mockedTime = new Date(structures.generator.timestamp())
|
|
210
|
+
tk.freeze(mockedTime)
|
|
211
|
+
|
|
212
|
+
const userData = {
|
|
213
|
+
externalId: structures.uuid(),
|
|
214
|
+
email: structures.generator.email(),
|
|
215
|
+
firstName: structures.generator.first(),
|
|
216
|
+
lastName: structures.generator.last(),
|
|
217
|
+
username: structures.generator.name(),
|
|
218
|
+
}
|
|
219
|
+
const body = structures.scim.createUserRequest(userData)
|
|
220
|
+
|
|
221
|
+
const response = await postScimUser({ body })
|
|
222
|
+
|
|
223
|
+
const expectedScimUser = {
|
|
224
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
225
|
+
id: expect.any(String),
|
|
226
|
+
externalId: userData.externalId,
|
|
227
|
+
meta: {
|
|
228
|
+
resourceType: "User",
|
|
229
|
+
created: mockedTime.toISOString(),
|
|
230
|
+
lastModified: mockedTime.toISOString(),
|
|
231
|
+
},
|
|
232
|
+
userName: userData.username,
|
|
233
|
+
name: {
|
|
234
|
+
formatted: `${userData.firstName} ${userData.lastName}`,
|
|
235
|
+
familyName: userData.lastName,
|
|
236
|
+
givenName: userData.firstName,
|
|
237
|
+
},
|
|
238
|
+
active: true,
|
|
239
|
+
emails: [
|
|
240
|
+
{
|
|
241
|
+
value: userData.email,
|
|
242
|
+
type: "work",
|
|
243
|
+
primary: true,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
expect(response).toEqual(expectedScimUser)
|
|
248
|
+
|
|
249
|
+
const persistedUsers = await config.api.scimUsersAPI.get()
|
|
250
|
+
expect(persistedUsers).toEqual(
|
|
251
|
+
expect.objectContaining({
|
|
252
|
+
totalResults: 1,
|
|
253
|
+
Resources: [expectedScimUser],
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("a new user can minim information", async () => {
|
|
259
|
+
const userData = {
|
|
260
|
+
externalId: structures.uuid(),
|
|
261
|
+
email: structures.generator.email(),
|
|
262
|
+
username: structures.generator.name(),
|
|
263
|
+
firstName: undefined,
|
|
264
|
+
lastName: undefined,
|
|
265
|
+
}
|
|
266
|
+
const body = structures.scim.createUserRequest(userData)
|
|
267
|
+
|
|
268
|
+
const response = await postScimUser({ body })
|
|
269
|
+
|
|
270
|
+
const expectedScimUser = {
|
|
271
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
272
|
+
id: expect.any(String),
|
|
273
|
+
externalId: userData.externalId,
|
|
274
|
+
meta: {
|
|
275
|
+
resourceType: "User",
|
|
276
|
+
created: mocks.date.MOCK_DATE.toISOString(),
|
|
277
|
+
lastModified: mocks.date.MOCK_DATE.toISOString(),
|
|
278
|
+
},
|
|
279
|
+
userName: userData.username,
|
|
280
|
+
active: true,
|
|
281
|
+
emails: [
|
|
282
|
+
{
|
|
283
|
+
value: userData.email,
|
|
284
|
+
type: "work",
|
|
285
|
+
primary: true,
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
expect(response).toEqual(expectedScimUser)
|
|
290
|
+
|
|
291
|
+
const persistedUsers = await config.api.scimUsersAPI.get()
|
|
292
|
+
expect(persistedUsers.Resources).toEqual(
|
|
293
|
+
expect.arrayContaining([expectedScimUser])
|
|
294
|
+
)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it("an event is dispatched", async () => {
|
|
298
|
+
const body = structures.scim.createUserRequest()
|
|
299
|
+
|
|
300
|
+
await postScimUser({ body })
|
|
301
|
+
|
|
302
|
+
expect(events.user.created).toBeCalledTimes(1)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("if the username is an email, the user name will be used as email", async () => {
|
|
306
|
+
const email = structures.generator.email()
|
|
307
|
+
|
|
308
|
+
const body: ScimCreateUserRequest = structures.scim.createUserRequest(
|
|
309
|
+
{ username: email }
|
|
310
|
+
)
|
|
311
|
+
delete body.emails
|
|
312
|
+
|
|
313
|
+
await postScimUser({ body })
|
|
314
|
+
|
|
315
|
+
const user = await config.getUser(email)
|
|
316
|
+
expect(user).toBeDefined()
|
|
317
|
+
expect(user.email).toEqual(email)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("if multiple emails are provided, the first primary one is used as email", async () => {
|
|
321
|
+
const email = structures.generator.email()
|
|
322
|
+
|
|
323
|
+
const body: ScimCreateUserRequest = {
|
|
324
|
+
...structures.scim.createUserRequest(),
|
|
325
|
+
emails: [
|
|
326
|
+
{
|
|
327
|
+
primary: false,
|
|
328
|
+
type: "work",
|
|
329
|
+
value: structures.generator.email(),
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
primary: true,
|
|
333
|
+
type: "work",
|
|
334
|
+
value: email,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
primary: true,
|
|
338
|
+
type: "work",
|
|
339
|
+
value: structures.generator.email(),
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await postScimUser({ body })
|
|
345
|
+
|
|
346
|
+
const user = await config.getUser(email)
|
|
347
|
+
expect(user).toBeDefined()
|
|
348
|
+
expect(user.email).toEqual(email)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("if no email is provided and the user name is not an email, an exception is thrown", async () => {
|
|
352
|
+
const body: ScimCreateUserRequest = structures.scim.createUserRequest(
|
|
353
|
+
{ username: structures.generator.name() }
|
|
354
|
+
)
|
|
355
|
+
delete body.emails
|
|
356
|
+
|
|
357
|
+
await postScimUser({ body }, { expect: 500 })
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("creating an existing user name returns a conflict", async () => {
|
|
362
|
+
const body = structures.scim.createUserRequest()
|
|
363
|
+
|
|
364
|
+
await postScimUser({ body })
|
|
365
|
+
|
|
366
|
+
const res = await postScimUser({ body }, { expect: 409 })
|
|
367
|
+
expect((res as any).message).toBe("Email already in use")
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe("GET /api/global/scim/v2/users/:id", () => {
|
|
372
|
+
let user: ScimUserResponse
|
|
373
|
+
|
|
374
|
+
beforeAll(async () => {
|
|
375
|
+
const body = structures.scim.createUserRequest()
|
|
376
|
+
|
|
377
|
+
user = await config.api.scimUsersAPI.post({ body })
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
const findScimUser = config.api.scimUsersAPI.find
|
|
381
|
+
|
|
382
|
+
unauthorisedTests(findScimUser)
|
|
383
|
+
|
|
384
|
+
it("should return existing user", async () => {
|
|
385
|
+
const response = await findScimUser(user.id)
|
|
386
|
+
|
|
387
|
+
expect(response).toEqual(user)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it("should return 404 when requesting unexisting user id", async () => {
|
|
391
|
+
const response = await findScimUser(structures.uuid(), { expect: 404 })
|
|
392
|
+
|
|
393
|
+
expect(response).toEqual({
|
|
394
|
+
message: "missing",
|
|
395
|
+
status: 404,
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
describe("PATCH /api/global/scim/v2/users/:id", () => {
|
|
401
|
+
const patchScimUser = config.api.scimUsersAPI.patch
|
|
402
|
+
|
|
403
|
+
let user: ScimUserResponse
|
|
404
|
+
|
|
405
|
+
beforeEach(async () => {
|
|
406
|
+
const body = structures.scim.createUserRequest()
|
|
407
|
+
|
|
408
|
+
user = await config.api.scimUsersAPI.post({ body })
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
unauthorisedTests(patchScimUser)
|
|
412
|
+
|
|
413
|
+
it("an existing user can be updated", async () => {
|
|
414
|
+
const newUserName = structures.generator.name()
|
|
415
|
+
const newFamilyName = structures.generator.last()
|
|
416
|
+
const body: ScimUpdateRequest = {
|
|
417
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
418
|
+
Operations: [
|
|
419
|
+
{
|
|
420
|
+
op: "Replace",
|
|
421
|
+
path: "userName",
|
|
422
|
+
value: newUserName,
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
op: "Replace",
|
|
426
|
+
path: "name.familyName",
|
|
427
|
+
value: newFamilyName,
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const response = await patchScimUser({ id: user.id, body })
|
|
433
|
+
|
|
434
|
+
const expectedScimUser: ScimUserResponse = {
|
|
435
|
+
...user,
|
|
436
|
+
userName: newUserName,
|
|
437
|
+
name: {
|
|
438
|
+
...user.name,
|
|
439
|
+
familyName: newFamilyName,
|
|
440
|
+
formatted: `${user.name!.givenName} ${newFamilyName}`,
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
expect(response).toEqual(expectedScimUser)
|
|
444
|
+
|
|
445
|
+
const persistedUser = await config.api.scimUsersAPI.find(user.id)
|
|
446
|
+
expect(persistedUser).toEqual(expectedScimUser)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it.each([false, "false", "False"])(
|
|
450
|
+
"deactivating an active user (sending %s) will delete it",
|
|
451
|
+
async activeValue => {
|
|
452
|
+
const body: ScimUpdateRequest = {
|
|
453
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
454
|
+
Operations: [{ op: "Replace", path: "active", value: activeValue }],
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await patchScimUser(
|
|
458
|
+
{ id: user.id, body },
|
|
459
|
+
{ expect: 204, skipContentTypeCheck: true }
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
await config.api.scimUsersAPI.find(user.id, { expect: 404 })
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
it("supports updating unmapped fields", async () => {
|
|
467
|
+
const value = structures.generator.letter()
|
|
468
|
+
const body: ScimUpdateRequest = {
|
|
469
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
470
|
+
Operations: [
|
|
471
|
+
{
|
|
472
|
+
op: "Add",
|
|
473
|
+
path: "preferredLanguage",
|
|
474
|
+
value,
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const response = await patchScimUser({ id: user.id, body })
|
|
480
|
+
|
|
481
|
+
const expectedScimUser = {
|
|
482
|
+
...user,
|
|
483
|
+
preferredLanguage: value,
|
|
484
|
+
}
|
|
485
|
+
expect(response).toEqual(expectedScimUser)
|
|
486
|
+
|
|
487
|
+
const persistedUser = await config.api.scimUsersAPI.find(user.id)
|
|
488
|
+
expect(persistedUser).toEqual(expectedScimUser)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("an event is dispatched", async () => {
|
|
492
|
+
const body: ScimUpdateRequest = {
|
|
493
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
494
|
+
Operations: [
|
|
495
|
+
{
|
|
496
|
+
op: "Replace",
|
|
497
|
+
path: "userName",
|
|
498
|
+
value: structures.generator.name(),
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await patchScimUser({ id: user.id, body })
|
|
504
|
+
|
|
505
|
+
expect(events.user.updated).toBeCalledTimes(1)
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
describe("DELETE /api/global/scim/v2/users/:id", () => {
|
|
510
|
+
const deleteScimUser = config.api.scimUsersAPI.delete
|
|
511
|
+
|
|
512
|
+
let user: ScimUserResponse
|
|
513
|
+
|
|
514
|
+
beforeEach(async () => {
|
|
515
|
+
const body = structures.scim.createUserRequest()
|
|
516
|
+
|
|
517
|
+
user = await config.api.scimUsersAPI.post({ body })
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
unauthorisedTests(deleteScimUser)
|
|
521
|
+
|
|
522
|
+
it("an existing user can be deleted", async () => {
|
|
523
|
+
const response = await deleteScimUser(user.id, { expect: 204 })
|
|
524
|
+
|
|
525
|
+
expect(response).toEqual({})
|
|
526
|
+
|
|
527
|
+
await config.api.scimUsersAPI.find(user.id, { expect: 404 })
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it("an non existing user can not be deleted", async () => {
|
|
531
|
+
await deleteScimUser(structures.uuid(), { expect: 404 })
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it("an event is dispatched", async () => {
|
|
535
|
+
await deleteScimUser(user.id, { expect: 204 })
|
|
536
|
+
|
|
537
|
+
expect(events.user.deleted).toBeCalledTimes(1)
|
|
538
|
+
})
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
describe("/api/global/scim/v2/groups", () => {
|
|
543
|
+
describe("GET /api/global/scim/v2/groups", () => {
|
|
544
|
+
const getScimGroups = config.api.scimGroupsAPI.get
|
|
545
|
+
|
|
546
|
+
unauthorisedTests(getScimGroups)
|
|
547
|
+
|
|
548
|
+
describe("no groups exist", () => {
|
|
549
|
+
it("should retrieve empty list", async () => {
|
|
550
|
+
const response = await getScimGroups()
|
|
551
|
+
|
|
552
|
+
expect(response).toEqual({
|
|
553
|
+
Resources: [],
|
|
554
|
+
itemsPerPage: 0,
|
|
555
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
556
|
+
startIndex: 1,
|
|
557
|
+
totalResults: 0,
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe("multiple groups exist", () => {
|
|
563
|
+
const groupCount = 25
|
|
564
|
+
let groups: ScimGroupResponse[]
|
|
565
|
+
|
|
566
|
+
beforeAll(async () => {
|
|
567
|
+
groups = []
|
|
568
|
+
|
|
569
|
+
for (let i = 0; i < groupCount; i++) {
|
|
570
|
+
const body = structures.scim.createGroupRequest()
|
|
571
|
+
groups.push(await config.api.scimGroupsAPI.post({ body }))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
groups = groups.sort((a, b) => (a.id > b.id ? 1 : -1))
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it("can fetch all groups without filters", async () => {
|
|
578
|
+
const response = await getScimGroups()
|
|
579
|
+
|
|
580
|
+
expect(response).toEqual({
|
|
581
|
+
Resources: expect.arrayContaining(groups),
|
|
582
|
+
itemsPerPage: 25,
|
|
583
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
584
|
+
startIndex: 1,
|
|
585
|
+
totalResults: groupCount,
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it("can fetch groups using displayName filters", async () => {
|
|
590
|
+
const groupToFetch = _.sample(groups)
|
|
591
|
+
const response = await getScimGroups({
|
|
592
|
+
params: { filter: `displayName eq "${groupToFetch!.displayName}"` },
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
expect(response).toEqual({
|
|
596
|
+
Resources: [groupToFetch],
|
|
597
|
+
itemsPerPage: 1,
|
|
598
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
599
|
+
startIndex: 1,
|
|
600
|
+
totalResults: 1,
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it("can fetch groups excluding members", async () => {
|
|
605
|
+
const response = await getScimGroups({
|
|
606
|
+
params: { excludedAttributes: "members" },
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
expect(response).toEqual({
|
|
610
|
+
Resources: expect.arrayContaining(
|
|
611
|
+
groups.map(g => {
|
|
612
|
+
const { members, ...groupData } = g
|
|
613
|
+
return groupData
|
|
614
|
+
})
|
|
615
|
+
),
|
|
616
|
+
itemsPerPage: 25,
|
|
617
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
618
|
+
startIndex: 1,
|
|
619
|
+
totalResults: groupCount,
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("can fetch groups excluding multiple fields", async () => {
|
|
624
|
+
const response = await getScimGroups({
|
|
625
|
+
params: { excludedAttributes: "members,displayName" },
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
expect(response).toEqual({
|
|
629
|
+
Resources: expect.arrayContaining(
|
|
630
|
+
groups.map(g => {
|
|
631
|
+
const { members, displayName, ...groupData } = g
|
|
632
|
+
return groupData
|
|
633
|
+
})
|
|
634
|
+
),
|
|
635
|
+
itemsPerPage: 25,
|
|
636
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
637
|
+
startIndex: 1,
|
|
638
|
+
totalResults: groupCount,
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
})
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
describe("POST /api/global/scim/v2/groups", () => {
|
|
645
|
+
const postScimGroup = config.api.scimGroupsAPI.post
|
|
646
|
+
|
|
647
|
+
beforeAll(async () => {
|
|
648
|
+
await config.useNewTenant()
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
unauthorisedTests(postScimGroup)
|
|
652
|
+
|
|
653
|
+
describe("no groups exist", () => {
|
|
654
|
+
it("a new group can be created and persisted", async () => {
|
|
655
|
+
const mockedTime = new Date(structures.generator.timestamp())
|
|
656
|
+
tk.freeze(mockedTime)
|
|
657
|
+
|
|
658
|
+
const groupData = {
|
|
659
|
+
externalId: structures.uuid(),
|
|
660
|
+
displayName: structures.generator.word(),
|
|
661
|
+
}
|
|
662
|
+
const body = structures.scim.createGroupRequest(groupData)
|
|
663
|
+
|
|
664
|
+
const response = await postScimGroup({ body })
|
|
665
|
+
|
|
666
|
+
const expectedScimGroup = {
|
|
667
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
|
668
|
+
id: expect.any(String),
|
|
669
|
+
externalId: groupData.externalId,
|
|
670
|
+
displayName: groupData.displayName,
|
|
671
|
+
meta: {
|
|
672
|
+
resourceType: "Group",
|
|
673
|
+
created: mockedTime.toISOString(),
|
|
674
|
+
lastModified: mockedTime.toISOString(),
|
|
675
|
+
},
|
|
676
|
+
members: [],
|
|
677
|
+
}
|
|
678
|
+
expect(response).toEqual(expectedScimGroup)
|
|
679
|
+
|
|
680
|
+
const persistedGroups = await config.api.scimGroupsAPI.get()
|
|
681
|
+
expect(persistedGroups).toEqual(
|
|
682
|
+
expect.objectContaining({
|
|
683
|
+
totalResults: 1,
|
|
684
|
+
Resources: [expectedScimGroup],
|
|
685
|
+
})
|
|
686
|
+
)
|
|
687
|
+
})
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
describe("GET /api/global/scim/v2/groups/:id", () => {
|
|
692
|
+
let group: ScimGroupResponse
|
|
693
|
+
|
|
694
|
+
beforeAll(async () => {
|
|
695
|
+
const body = structures.scim.createGroupRequest()
|
|
696
|
+
|
|
697
|
+
group = await config.api.scimGroupsAPI.post({ body })
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
const findScimGroup = config.api.scimGroupsAPI.find
|
|
701
|
+
|
|
702
|
+
unauthorisedTests(findScimGroup)
|
|
703
|
+
|
|
704
|
+
it("should return existing group", async () => {
|
|
705
|
+
const response = await findScimGroup(group.id)
|
|
706
|
+
|
|
707
|
+
expect(response).toEqual(group)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it("should return 404 when requesting unexisting group id", async () => {
|
|
711
|
+
const response = await findScimGroup(structures.uuid(), { expect: 404 })
|
|
712
|
+
|
|
713
|
+
expect(response).toEqual({
|
|
714
|
+
message: "missing",
|
|
715
|
+
status: 404,
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it("should allow excluding members", async () => {
|
|
720
|
+
const response = await findScimGroup(group.id, {
|
|
721
|
+
qs: "excludedAttributes=members",
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
const { members, ...expectedResponse } = group
|
|
725
|
+
|
|
726
|
+
expect(response).toEqual(expectedResponse)
|
|
727
|
+
})
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
describe("DELETE /api/global/scim/v2/groups/:id", () => {
|
|
731
|
+
const deleteScimGroup = config.api.scimGroupsAPI.delete
|
|
732
|
+
|
|
733
|
+
let group: ScimGroupResponse
|
|
734
|
+
|
|
735
|
+
beforeAll(async () => {
|
|
736
|
+
const body = structures.scim.createGroupRequest()
|
|
737
|
+
|
|
738
|
+
group = await config.api.scimGroupsAPI.post({ body })
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
unauthorisedTests(deleteScimGroup)
|
|
742
|
+
|
|
743
|
+
it("an existing group can be deleted", async () => {
|
|
744
|
+
const response = await deleteScimGroup(group.id, { expect: 204 })
|
|
745
|
+
|
|
746
|
+
expect(response).toEqual({})
|
|
747
|
+
|
|
748
|
+
await config.api.scimGroupsAPI.find(group.id, { expect: 404 })
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it("an non existing group can not be deleted", async () => {
|
|
752
|
+
await deleteScimGroup(structures.uuid(), { expect: 404 })
|
|
753
|
+
})
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
describe("PATCH /api/global/scim/v2/groups/:id", () => {
|
|
757
|
+
const patchScimGroup = config.api.scimGroupsAPI.patch
|
|
758
|
+
|
|
759
|
+
let group: ScimGroupResponse
|
|
760
|
+
let users: ScimUserResponse[]
|
|
761
|
+
|
|
762
|
+
beforeAll(async () => {
|
|
763
|
+
users = []
|
|
764
|
+
for (let i = 0; i < 30; i++) {
|
|
765
|
+
const body = structures.scim.createUserRequest()
|
|
766
|
+
users.push(await config.api.scimUsersAPI.post({ body }))
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
users = users.sort((a, b) => (a.id > b.id ? 1 : -1))
|
|
770
|
+
|
|
771
|
+
const body = structures.scim.createGroupRequest()
|
|
772
|
+
group = await config.api.scimGroupsAPI.post({ body })
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
unauthorisedTests(patchScimGroup)
|
|
776
|
+
|
|
777
|
+
it("an existing group can be updated", async () => {
|
|
778
|
+
const newDisplayName = structures.generator.word()
|
|
779
|
+
|
|
780
|
+
const body: ScimUpdateRequest = {
|
|
781
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
782
|
+
Operations: [
|
|
783
|
+
{
|
|
784
|
+
op: "Replace",
|
|
785
|
+
path: "displayName",
|
|
786
|
+
value: newDisplayName,
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const response = await patchScimGroup({ id: group.id, body })
|
|
792
|
+
|
|
793
|
+
const expectedScimGroup = {
|
|
794
|
+
...group,
|
|
795
|
+
displayName: newDisplayName,
|
|
796
|
+
}
|
|
797
|
+
expect(response).toEqual(expectedScimGroup)
|
|
798
|
+
|
|
799
|
+
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
|
800
|
+
expect(persistedGroup).toEqual(expectedScimGroup)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
describe("adding users", () => {
|
|
804
|
+
beforeAll(async () => {
|
|
805
|
+
const body = structures.scim.createGroupRequest()
|
|
806
|
+
group = await config.api.scimGroupsAPI.post({ body })
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it("a new user can be added to an existing group", async () => {
|
|
810
|
+
const userToAdd = users[0]
|
|
811
|
+
|
|
812
|
+
const body: ScimUpdateRequest = {
|
|
813
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
814
|
+
Operations: [
|
|
815
|
+
{
|
|
816
|
+
op: "Add",
|
|
817
|
+
path: "members",
|
|
818
|
+
value: [
|
|
819
|
+
{
|
|
820
|
+
$ref: null,
|
|
821
|
+
value: userToAdd.id,
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
},
|
|
825
|
+
],
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const response = await patchScimGroup({ id: group.id, body })
|
|
829
|
+
|
|
830
|
+
const expectedScimGroup: ScimGroupResponse = {
|
|
831
|
+
...group,
|
|
832
|
+
members: [
|
|
833
|
+
{
|
|
834
|
+
value: userToAdd.id,
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
}
|
|
838
|
+
expect(response).toEqual(expectedScimGroup)
|
|
839
|
+
|
|
840
|
+
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
|
841
|
+
expect(persistedGroup).toEqual(expectedScimGroup)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it("multiple users can be added to an existing group", async () => {
|
|
845
|
+
const body: ScimUpdateRequest = {
|
|
846
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
847
|
+
Operations: [
|
|
848
|
+
{
|
|
849
|
+
op: "Add",
|
|
850
|
+
path: "members",
|
|
851
|
+
value: [
|
|
852
|
+
{
|
|
853
|
+
$ref: null,
|
|
854
|
+
value: users[1].id,
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
$ref: null,
|
|
858
|
+
value: users[2].id,
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
$ref: null,
|
|
862
|
+
value: users[3].id,
|
|
863
|
+
},
|
|
864
|
+
],
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const response = await patchScimGroup({ id: group.id, body })
|
|
870
|
+
|
|
871
|
+
const expectedScimGroup: ScimGroupResponse = {
|
|
872
|
+
...group,
|
|
873
|
+
members: [
|
|
874
|
+
{
|
|
875
|
+
value: users[0].id,
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
value: users[1].id,
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
value: users[2].id,
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
value: users[3].id,
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
}
|
|
888
|
+
expect(response).toEqual(expectedScimGroup)
|
|
889
|
+
|
|
890
|
+
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
|
891
|
+
expect(persistedGroup).toEqual(expectedScimGroup)
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it("existing users can be removed from to an existing group", async () => {
|
|
895
|
+
const body: ScimUpdateRequest = {
|
|
896
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
897
|
+
Operations: [
|
|
898
|
+
{
|
|
899
|
+
op: "Remove",
|
|
900
|
+
path: "members",
|
|
901
|
+
value: [
|
|
902
|
+
{
|
|
903
|
+
$ref: null,
|
|
904
|
+
value: users[0].id,
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
$ref: null,
|
|
908
|
+
value: users[2].id,
|
|
909
|
+
},
|
|
910
|
+
],
|
|
911
|
+
},
|
|
912
|
+
],
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const response = await patchScimGroup({ id: group.id, body })
|
|
916
|
+
|
|
917
|
+
const expectedScimGroup: ScimGroupResponse = {
|
|
918
|
+
...group,
|
|
919
|
+
members: expect.arrayContaining([
|
|
920
|
+
{
|
|
921
|
+
value: users[1].id,
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
value: users[3].id,
|
|
925
|
+
},
|
|
926
|
+
]),
|
|
927
|
+
}
|
|
928
|
+
expect(response).toEqual(expectedScimGroup)
|
|
929
|
+
|
|
930
|
+
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
|
931
|
+
expect(persistedGroup).toEqual(expectedScimGroup)
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
it("adding and removing can be added in a single operation", async () => {
|
|
935
|
+
const body: ScimUpdateRequest = {
|
|
936
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
937
|
+
Operations: [
|
|
938
|
+
{
|
|
939
|
+
op: "Remove",
|
|
940
|
+
path: "members",
|
|
941
|
+
value: [
|
|
942
|
+
{
|
|
943
|
+
$ref: null,
|
|
944
|
+
value: users[1].id,
|
|
945
|
+
},
|
|
946
|
+
],
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
op: "Add",
|
|
950
|
+
path: "members",
|
|
951
|
+
value: [
|
|
952
|
+
{
|
|
953
|
+
$ref: null,
|
|
954
|
+
value: users[4].id,
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const response = await patchScimGroup({ id: group.id, body })
|
|
962
|
+
|
|
963
|
+
const expectedScimGroup: ScimGroupResponse = {
|
|
964
|
+
...group,
|
|
965
|
+
members: expect.arrayContaining([
|
|
966
|
+
{
|
|
967
|
+
value: users[3].id,
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
value: users[4].id,
|
|
971
|
+
},
|
|
972
|
+
]),
|
|
973
|
+
}
|
|
974
|
+
expect(response).toEqual(expectedScimGroup)
|
|
975
|
+
|
|
976
|
+
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
|
977
|
+
expect(persistedGroup).toEqual(expectedScimGroup)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it("adding members and updating fields can performed in a single operation", async () => {
|
|
981
|
+
const newDisplayName = structures.generator.word()
|
|
982
|
+
|
|
983
|
+
const body: ScimUpdateRequest = {
|
|
984
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
985
|
+
Operations: [
|
|
986
|
+
{
|
|
987
|
+
op: "Replace",
|
|
988
|
+
path: "displayName",
|
|
989
|
+
value: newDisplayName,
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
op: "Add",
|
|
993
|
+
path: "members",
|
|
994
|
+
value: [
|
|
995
|
+
{
|
|
996
|
+
$ref: null,
|
|
997
|
+
value: users[5].id,
|
|
998
|
+
},
|
|
999
|
+
],
|
|
1000
|
+
},
|
|
1001
|
+
],
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const response = await patchScimGroup({ id: group.id, body })
|
|
1005
|
+
|
|
1006
|
+
const expectedScimGroup: ScimGroupResponse = {
|
|
1007
|
+
...group,
|
|
1008
|
+
displayName: newDisplayName,
|
|
1009
|
+
members: expect.arrayContaining([
|
|
1010
|
+
{
|
|
1011
|
+
value: users[3].id,
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
value: users[4].id,
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
value: users[5].id,
|
|
1018
|
+
},
|
|
1019
|
+
]),
|
|
1020
|
+
}
|
|
1021
|
+
expect(response).toEqual(expectedScimGroup)
|
|
1022
|
+
|
|
1023
|
+
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
|
1024
|
+
expect(persistedGroup).toEqual(expectedScimGroup)
|
|
1025
|
+
})
|
|
1026
|
+
})
|
|
1027
|
+
})
|
|
1028
|
+
})
|
|
1029
|
+
})
|