@tellescope/sdk 1.255.6 → 1.255.8

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 (54) hide show
  1. package/lib/cjs/sdk.d.ts +2 -2
  2. package/lib/cjs/session.d.ts +1 -0
  3. package/lib/cjs/session.d.ts.map +1 -1
  4. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  5. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +258 -32
  6. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  7. package/lib/cjs/tests/api_tests/gmail_sync_related_contact_emails.test.d.ts +6 -0
  8. package/lib/cjs/tests/api_tests/gmail_sync_related_contact_emails.test.d.ts.map +1 -0
  9. package/lib/cjs/tests/api_tests/gmail_sync_related_contact_emails.test.js +183 -0
  10. package/lib/cjs/tests/api_tests/gmail_sync_related_contact_emails.test.js.map +1 -0
  11. package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.d.ts +6 -0
  12. package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.d.ts.map +1 -0
  13. package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.js +250 -0
  14. package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.js.map +1 -0
  15. package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.d.ts +36 -0
  16. package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.d.ts.map +1 -0
  17. package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.js +203 -0
  18. package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.js.map +1 -0
  19. package/lib/cjs/tests/tests.d.ts.map +1 -1
  20. package/lib/cjs/tests/tests.js +174 -166
  21. package/lib/cjs/tests/tests.js.map +1 -1
  22. package/lib/esm/enduser.d.ts +0 -1
  23. package/lib/esm/enduser.d.ts.map +1 -1
  24. package/lib/esm/sdk.d.ts +2 -3
  25. package/lib/esm/sdk.d.ts.map +1 -1
  26. package/lib/esm/tests/api_tests/gmail_sync_related_contact_emails.test.d.ts +6 -0
  27. package/lib/esm/tests/api_tests/gmail_sync_related_contact_emails.test.d.ts.map +1 -0
  28. package/lib/esm/tests/api_tests/gmail_sync_related_contact_emails.test.js +179 -0
  29. package/lib/esm/tests/api_tests/gmail_sync_related_contact_emails.test.js.map +1 -0
  30. package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.d.ts +6 -0
  31. package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.d.ts.map +1 -0
  32. package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.js +223 -0
  33. package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.js.map +1 -0
  34. package/lib/esm/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
  35. package/lib/esm/tests/api_tests/medication_added_trigger.test.js +136 -206
  36. package/lib/esm/tests/api_tests/medication_added_trigger.test.js.map +1 -1
  37. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +0 -10
  38. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -1
  39. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +41 -184
  40. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -1
  41. package/lib/esm/tests/api_tests/security/register_business_id_injection.test.d.ts +36 -0
  42. package/lib/esm/tests/api_tests/security/register_business_id_injection.test.d.ts.map +1 -0
  43. package/lib/esm/tests/api_tests/security/register_business_id_injection.test.js +196 -0
  44. package/lib/esm/tests/api_tests/security/register_business_id_injection.test.js.map +1 -0
  45. package/lib/esm/tests/tests.d.ts.map +1 -1
  46. package/lib/esm/tests/tests.js +127 -131
  47. package/lib/esm/tests/tests.js.map +1 -1
  48. package/lib/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +8 -8
  50. package/src/tests/api_tests/custom_dashboards.test.ts +208 -0
  51. package/src/tests/api_tests/journey_delete_cancels_actions.test.ts +152 -0
  52. package/src/tests/api_tests/security/register_business_id_injection.test.ts +140 -0
  53. package/src/tests/tests.ts +5 -1
  54. package/test_generated.pdf +0 -0
@@ -4,6 +4,7 @@ import { Session } from "../../sdk"
4
4
  import {
5
5
  async_test,
6
6
  log_header,
7
+ wait,
7
8
  } from "@tellescope/testing"
8
9
  import { setup_tests } from "../setup"
9
10
 
@@ -199,6 +200,56 @@ export const custom_dashboards_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
199
200
  }
200
201
  )
201
202
 
203
+ // Test 6b: Create dashboard with every built-in block type (legacy renderer set)
204
+ const dashboardWithBuiltInTypes = await sdk.api.custom_dashboards.createOne({
205
+ title: "Built-In Block Types Dashboard",
206
+ blocks: [
207
+ { type: "Inbox" },
208
+ { type: "Tickets" },
209
+ { type: "Team Chats" },
210
+ { type: "Upcoming Events" },
211
+ { type: "To-Dos" },
212
+ { type: "Database", info: { databaseId: "60398b0231a295e64f084fd9" } },
213
+ ],
214
+ })
215
+
216
+ await async_test(
217
+ "Dashboard with all built-in block types created correctly",
218
+ async () => dashboardWithBuiltInTypes,
219
+ {
220
+ onResult: r => (
221
+ r.blocks.length === 6
222
+ && r.blocks[0].type === "Inbox"
223
+ && r.blocks[1].type === "Tickets"
224
+ && r.blocks[2].type === "Team Chats"
225
+ && r.blocks[3].type === "Upcoming Events"
226
+ && r.blocks[4].type === "To-Dos"
227
+ && r.blocks[5].type === "Database"
228
+ && r.blocks[5].info !== undefined && r.blocks[5].info.databaseId === "60398b0231a295e64f084fd9"
229
+ )
230
+ }
231
+ )
232
+
233
+ // Test 6c: Unknown top-level block fields are stripped on save (params belong in info)
234
+ const dashboardWithUnknownBlockField = await sdk.api.custom_dashboards.createOne({
235
+ title: "Unknown Block Field Dashboard",
236
+ blocks: [
237
+ { type: "Inbox", customTopLevel: "x", info: { kept: true } } as any,
238
+ ],
239
+ })
240
+
241
+ await async_test(
242
+ "Unknown top-level block fields stripped, info preserved",
243
+ async () => dashboardWithUnknownBlockField,
244
+ {
245
+ onResult: r => (
246
+ r.blocks.length === 1
247
+ && (r.blocks[0] as any).customTopLevel === undefined
248
+ && r.blocks[0].info !== undefined && r.blocks[0].info.kept === true
249
+ )
250
+ }
251
+ )
252
+
202
253
  // Test 7: Update gridConfig only
203
254
  const updatedGridConfig = await sdk.api.custom_dashboards.updateOne(dashboardWithNewTypes.id, {
204
255
  gridConfig: { columns: 24, gap: 8 },
@@ -215,6 +266,67 @@ export const custom_dashboards_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
215
266
  }
216
267
  )
217
268
 
269
+ // Test 7b: Create dashboard with top-level type field
270
+ const typedDashboard = await sdk.api.custom_dashboards.createOne({
271
+ title: "Typed Dashboard",
272
+ type: "home",
273
+ blocks: [{ type: "Inbox", colSpan: 12 }],
274
+ })
275
+
276
+ await async_test(
277
+ "Dashboard with top-level type created correctly",
278
+ async () => typedDashboard,
279
+ { onResult: r => r.type === "home" }
280
+ )
281
+
282
+ // Test 7c: Dashboards without type still work (backwards compatibility)
283
+ await async_test(
284
+ "Dashboard without type has undefined type",
285
+ async () => sdk.api.custom_dashboards.getOne(basicDashboard.id),
286
+ { onResult: r => r.type === undefined }
287
+ )
288
+
289
+ // Test 7d: Update top-level type
290
+ const updatedType = await sdk.api.custom_dashboards.updateOne(typedDashboard.id, {
291
+ type: "clinical",
292
+ })
293
+
294
+ await async_test(
295
+ "Dashboard type updated correctly",
296
+ async () => updatedType,
297
+ { onResult: r => r.type === "clinical" }
298
+ )
299
+
300
+ // Test 7e: Filter dashboards by type
301
+ const secondTypedDashboard = await sdk.api.custom_dashboards.createOne({
302
+ title: "Second Typed Dashboard",
303
+ type: "clinical",
304
+ blocks: [{ type: "Tickets", colSpan: 12 }],
305
+ })
306
+
307
+ await async_test(
308
+ "Dashboards filtered by type via filter",
309
+ async () => sdk.api.custom_dashboards.getSome({ filter: { type: "clinical" } }),
310
+ {
311
+ onResult: r => (
312
+ r.length === 2
313
+ && r.every(d => d.type === "clinical")
314
+ )
315
+ }
316
+ )
317
+
318
+ // Test 7f: Filter dashboards by type via mdbFilter
319
+ await async_test(
320
+ "Dashboards filtered by type via mdbFilter",
321
+ async () => sdk.api.custom_dashboards.getSome({ mdbFilter: { type: "clinical" } }),
322
+ {
323
+ onResult: r => (
324
+ r.length === 2
325
+ && r.every(d => d.type === "clinical")
326
+ )
327
+ }
328
+ )
329
+
218
330
  // Test 8: Non-admin can read all dashboards by default
219
331
  const nonAdminList = await sdkNonAdmin.api.custom_dashboards.getSome({ filter: {} })
220
332
 
@@ -224,14 +336,110 @@ export const custom_dashboards_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
224
336
  { onResult: r => r.length >= 2 }
225
337
  )
226
338
 
339
+ // Test 9: visibleToAllUsers grants org-wide access to users without full read access
340
+ const restrictedRole = await sdk.api.role_based_access_permissions.createOne({
341
+ role: 'Dashboard Default Access',
342
+ permissions: {
343
+ custom_dashboards: { read: 'Default', create: null, update: null, delete: null },
344
+ },
345
+ })
346
+
347
+ const restrictedUserEmail = 'dashboard.restricted.test@tellescope.com'
348
+ let restrictedUser = await sdk.api.users.getOne({ email: restrictedUserEmail }).catch(() => null) // throws error on none found
349
+ if (restrictedUser && !restrictedUser.verifiedEmail) { // verifiedEmail can only be set on create, so recreate stale users
350
+ await sdk.api.users.deleteOne(restrictedUser.id)
351
+ restrictedUser = null
352
+ }
353
+ if (!restrictedUser) {
354
+ restrictedUser = await sdk.api.users.createOne({ email: restrictedUserEmail, notificationEmailsDisabled: true, verifiedEmail: true })
355
+ }
356
+ // ensure role is set, in case GET returned a user without a role or with a different role
357
+ await sdk.api.users.updateOne(restrictedUser.id, { roles: [restrictedRole.role] }, { replaceObjectFields: true })
358
+ await wait(undefined, 2000) // role change triggers a logout
359
+
360
+ const sdkRestricted = new Session({
361
+ host,
362
+ authToken: (await sdk.api.users.generate_auth_token({ id: restrictedUser.id })).authToken,
363
+ })
364
+ await async_test('test_authenticated (restricted dashboard user)', sdkRestricted.test_authenticated, { expectedResult: 'Authenticated!' })
365
+
366
+ const hiddenDashboard = await sdk.api.custom_dashboards.createOne({
367
+ title: "Hidden From Restricted",
368
+ blocks: [{ type: "Inbox", colSpan: 12 }],
369
+ })
370
+ const orgWideDashboard = await sdk.api.custom_dashboards.createOne({
371
+ title: "Org-Wide Dashboard",
372
+ visibleToAllUsers: true,
373
+ blocks: [{ type: "Inbox", colSpan: 12 }],
374
+ })
375
+ const explicitFalseDashboard = await sdk.api.custom_dashboards.createOne({
376
+ title: "Explicit False Dashboard",
377
+ visibleToAllUsers: false,
378
+ blocks: [{ type: "Inbox", colSpan: 12 }],
379
+ })
380
+ const assignedDashboard = await sdk.api.custom_dashboards.createOne({
381
+ title: "Assigned To Restricted",
382
+ userIds: [restrictedUser.id],
383
+ blocks: [{ type: "Inbox", colSpan: 12 }],
384
+ })
385
+
386
+ await async_test(
387
+ "Restricted user sees org-wide and assigned dashboards only",
388
+ () => sdkRestricted.api.custom_dashboards.getSome({ filter: {} }),
389
+ {
390
+ onResult: r => (
391
+ !!r.find(d => d.id === orgWideDashboard.id)
392
+ && !!r.find(d => d.id === assignedDashboard.id)
393
+ && !r.find(d => d.id === hiddenDashboard.id)
394
+ && !r.find(d => d.id === explicitFalseDashboard.id)
395
+ )
396
+ }
397
+ )
398
+
399
+ await async_test(
400
+ "Restricted user can read org-wide dashboard by id",
401
+ () => sdkRestricted.api.custom_dashboards.getOne(orgWideDashboard.id),
402
+ { onResult: r => r.id === orgWideDashboard.id && r.visibleToAllUsers === true }
403
+ )
404
+
405
+ await async_test(
406
+ "Restricted user cannot read unshared dashboard by id",
407
+ () => sdkRestricted.api.custom_dashboards.getOne(hiddenDashboard.id),
408
+ { shouldError: true, onError: () => true }
409
+ )
410
+
411
+ // Toggling the flag on takes effect for the restricted user
412
+ await sdk.api.custom_dashboards.updateOne(hiddenDashboard.id, { visibleToAllUsers: true })
413
+
414
+ await async_test(
415
+ "Restricted user can read dashboard after visibleToAllUsers enabled",
416
+ () => sdkRestricted.api.custom_dashboards.getOne(hiddenDashboard.id),
417
+ { onResult: r => r.id === hiddenDashboard.id }
418
+ )
419
+
420
+ // Admin continues to see everything, including unshared dashboards
421
+ await async_test(
422
+ "Admin still sees explicit-false dashboard",
423
+ () => sdk.api.custom_dashboards.getOne(explicitFalseDashboard.id),
424
+ { onResult: r => r.id === explicitFalseDashboard.id }
425
+ )
426
+
227
427
  // Cleanup
228
428
  await Promise.all([
429
+ sdk.api.custom_dashboards.deleteOne(hiddenDashboard.id),
430
+ sdk.api.custom_dashboards.deleteOne(orgWideDashboard.id),
431
+ sdk.api.custom_dashboards.deleteOne(explicitFalseDashboard.id),
432
+ sdk.api.custom_dashboards.deleteOne(assignedDashboard.id),
229
433
  sdk.api.custom_dashboards.deleteOne(basicDashboard.id),
230
434
  sdk.api.custom_dashboards.deleteOne(duplicateTitleDashboard.id),
231
435
  sdk.api.custom_dashboards.deleteOne(fullDashboard.id),
232
436
  sdk.api.custom_dashboards.deleteOne(dashboardWithUserIds.id),
233
437
  sdk.api.custom_dashboards.deleteOne(dashboardWithNewTypes.id),
234
438
  sdk.api.custom_dashboards.deleteOne(dashboardWithComplexBlocks.id),
439
+ sdk.api.custom_dashboards.deleteOne(dashboardWithBuiltInTypes.id),
440
+ sdk.api.custom_dashboards.deleteOne(dashboardWithUnknownBlockField.id),
441
+ sdk.api.custom_dashboards.deleteOne(typedDashboard.id),
442
+ sdk.api.custom_dashboards.deleteOne(secondTypedDashboard.id),
235
443
  ])
236
444
  }
237
445
 
@@ -0,0 +1,152 @@
1
+ import { Session } from '../../sdk'
2
+ import { log_header, wait } from "@tellescope/testing"
3
+ import { AUTOMATED_ACTION_CANCEL_REASONS } from "@tellescope/constants"
4
+
5
+ const host = process.env.API_URL || 'http://localhost:8080'
6
+
7
+ export const journey_delete_cancels_actions_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
8
+ log_header("Journey Delete Cancels Pending Actions Tests")
9
+
10
+ let journeyId: string | undefined
11
+ let enduserId: string | undefined
12
+ let stepId: string | undefined
13
+
14
+ try {
15
+ // Create a journey + an automation_step
16
+ const journey = await sdk.api.journeys.createOne({ title: "Cascade Cancel Journey " + Date.now() })
17
+ journeyId = journey.id
18
+
19
+ const step = await sdk.api.automation_steps.createOne({
20
+ journeyId: journey.id,
21
+ action: { type: 'setEnduserStatus', info: { status: 'Test Status' } },
22
+ events: [{ type: 'onJourneyStart', info: {} }],
23
+ })
24
+ stepId = step.id
25
+
26
+ // Create an enduser
27
+ const enduser = await sdk.api.endusers.createOne({
28
+ fname: 'Cascade',
29
+ lname: 'Cancel',
30
+ email: 'test-cascade-cancel-' + Date.now() + '@example.com',
31
+ })
32
+ enduserId = enduser.id
33
+
34
+ // Create an active action with a far-future processAfter so the worker won't process it during the test
35
+ const activeAction = await sdk.api.automated_actions.createOne({
36
+ journeyId: journey.id,
37
+ automationStepId: step.id,
38
+ enduserId: enduser.id,
39
+ processAfter: Date.now() + 1000000, // far future so the worker won't process it
40
+ status: 'active',
41
+ event: { type: 'onJourneyStart', info: {} },
42
+ action: { type: 'setEnduserStatus', info: { status: 'Test Status' } },
43
+ })
44
+
45
+ // Create a finished action to confirm it is left untouched by the cascade
46
+ const finishedAction = await sdk.api.automated_actions.createOne({
47
+ journeyId: journey.id,
48
+ automationStepId: step.id,
49
+ enduserId: enduser.id,
50
+ processAfter: Date.now() + 1000000,
51
+ status: 'finished',
52
+ event: { type: 'onJourneyStart', info: {} },
53
+ action: { type: 'setEnduserStatus', info: { status: 'Test Status' } },
54
+ })
55
+
56
+ // Assert the action starts active
57
+ const before = await sdk.api.automated_actions.getOne(activeAction.id)
58
+ if (before.status !== 'active') {
59
+ throw new Error(`Expected action to start 'active', got '${before.status}'`)
60
+ }
61
+ console.log("✓ Action is active before journey deletion")
62
+
63
+ // Delete the journey — this should cascade cancel the pending action
64
+ await sdk.api.journeys.deleteOne(journey.id)
65
+ journeyId = undefined // already deleted, skip in cleanup
66
+
67
+ // Poll until the active action transitions to cancelled with the Journey Deleted reason,
68
+ // attributed to the deleting user via cancelledBy
69
+ const expectedReason = AUTOMATED_ACTION_CANCEL_REASONS.indexOf('Journey Deleted')
70
+ await pollForResults(
71
+ async () => {
72
+ const actions = await sdk.api.automated_actions.getSome({ filter: { enduserId: enduser.id } })
73
+ const active = actions.find(a => a.id === activeAction.id)
74
+ const finished = actions.find(a => a.id === finishedAction.id)
75
+ return { active, finished }
76
+ },
77
+ ({ active }) => (
78
+ active?.status === 'cancelled'
79
+ && active?.cancelReason === expectedReason
80
+ && active?.cancelledBy === sdk.userInfo.id
81
+ ),
82
+ 'Active action should be cancelled with Journey Deleted reason and cancelledBy after journey deletion',
83
+ )
84
+ console.log("✓ Active action cancelled with Journey Deleted reason and cancelledBy set to deleting user")
85
+
86
+ // Not covered here: journey deletion also removes outstanding_froms_trackers. Creating a tracker
87
+ // requires an automation to actually send an email/SMS with multiple form links, and the collection
88
+ // is internal-only (no API read access), so it can't be verified from an SDK test.
89
+
90
+ // Confirm the finished action was left untouched
91
+ const finishedAfter = await sdk.api.automated_actions.getOne(finishedAction.id)
92
+ if (finishedAfter.status !== 'finished') {
93
+ throw new Error(`Expected finished action to remain 'finished', got '${finishedAfter.status}'`)
94
+ }
95
+ console.log("✓ Finished action left untouched")
96
+
97
+ console.log("✅ Journey Delete Cancels Pending Actions tests completed successfully")
98
+ } finally {
99
+ // Cleanup — journey is already deleted if the flow completed
100
+ await Promise.all([
101
+ enduserId ? sdk.api.endusers.deleteOne(enduserId).catch(() => {}) : undefined,
102
+ stepId ? sdk.api.automation_steps.deleteOne(stepId).catch(() => {}) : undefined,
103
+ journeyId ? sdk.api.journeys.deleteOne(journeyId).catch(() => {}) : undefined,
104
+ ])
105
+ }
106
+ }
107
+
108
+ const pollForResults = async <T>(
109
+ fetchFn: () => Promise<T>,
110
+ evaluateFn: (result: T) => boolean,
111
+ description: string,
112
+ intervalMs = 500,
113
+ maxIterations = 30,
114
+ ): Promise<void> => {
115
+ let lastResult: T | undefined
116
+
117
+ for (let i = 0; i < maxIterations; i++) {
118
+ await wait(undefined, intervalMs)
119
+
120
+ lastResult = await fetchFn()
121
+ if (evaluateFn(lastResult)) {
122
+ console.log(`✓ ${description} - completed after ${(i + 1) * intervalMs}ms`)
123
+ return
124
+ }
125
+ }
126
+
127
+ console.log('Final polling result:', lastResult)
128
+ throw new Error(`Polling timeout: ${description} - waited ${maxIterations * intervalMs}ms`)
129
+ }
130
+
131
+ // Allow running this test file independently
132
+ if (require.main === module) {
133
+ console.log(`🌐 Using API URL: ${host}`)
134
+ const sdk = new Session({ host })
135
+ const sdkNonAdmin = new Session({ host })
136
+
137
+ const runTests = async () => {
138
+ const { setup_tests } = await import("../setup")
139
+ await setup_tests(sdk, sdkNonAdmin)
140
+ await journey_delete_cancels_actions_tests({ sdk, sdkNonAdmin })
141
+ }
142
+
143
+ runTests()
144
+ .then(() => {
145
+ console.log("✅ Journey Delete Cancels Pending Actions test suite completed successfully")
146
+ process.exit(0)
147
+ })
148
+ .catch((error) => {
149
+ console.error("❌ Journey Delete Cancels Pending Actions test suite failed:", error)
150
+ process.exit(1)
151
+ })
152
+ }
@@ -0,0 +1,140 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../../sdk"
5
+ import {
6
+ assert,
7
+ log_header,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ const post = async (path: string, body: any, headers: Record<string, string> = {}) => {
15
+ try {
16
+ const res = await axios.post(`${host}${path}`, body, {
17
+ validateStatus: () => true,
18
+ headers,
19
+ })
20
+ return { status: res.status, data: res.data }
21
+ } catch (err: any) {
22
+ return { status: err?.response?.status, data: err?.response?.data }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Reproduction + regression test for the reported vulnerability:
28
+ *
29
+ * A @wearehackerone.com email "joined" a customer's organization without being invited, and
30
+ * duplicate user records existed for that email — each bound to a different businessId.
31
+ *
32
+ * Root cause: the public `POST /v1/users/register` endpoint. `addPublicEndpoint` accepts an
33
+ * OPTIONAL, caller-supplied `businessId` (routing.ts) and bakes it into the handler's DB. The
34
+ * register handler's only duplicate guard (`DB.users.findOne({ email })`) is therefore scoped to
35
+ * whatever org the caller named, and the subsequent `DB.users.insertOne(...)` stamps that
36
+ * caller-supplied businessId onto a new `accountType: 'Business'` user — bypassing the
37
+ * globalUnique: ['email'] enforcement that only runs in the generic create route.
38
+ *
39
+ * The "victim org" here is the test admin's own org (sdk.userInfo.businessId): an org the
40
+ * anonymous caller was never invited to. The admin is used purely to OBSERVE whether an
41
+ * uninvited user record materialized inside their org (getOne({ email }) is org-scoped and
42
+ * throws when the email is absent from the caller's tenant).
43
+ *
44
+ * Expected results:
45
+ * - VULNERABLE code: the assertions FAIL — the planted user appears inside the victim org,
46
+ * proving the exploit ("EXPLOIT CONFIRMED" in the failure message).
47
+ * - FIXED code: register ignores the caller-supplied businessId (self-signups are created
48
+ * org-less and duplicates are rejected globally), so nothing appears in the victim org and
49
+ * the assertions PASS.
50
+ *
51
+ * Uses unique emails per run (Date.now()) so re-runs never collide on the platform-wide unique
52
+ * email constraint. NOTE: on the FIXED path these registrations create org-less user records
53
+ * that an org-scoped admin cannot delete — harmless residue (unique emails), inherent to the
54
+ * legitimate "root register, no org" signup behavior.
55
+ */
56
+ export const register_business_id_injection_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
57
+ log_header("register businessId injection")
58
+
59
+ const victimBusinessId = sdk.userInfo.businessId
60
+ const password = 'testpassword123!'
61
+ const termsVersion = '1'
62
+
63
+ // ==================================================================================
64
+ // Scenario 1 — uninvited org join
65
+ // An anonymous caller registers with businessId set to the victim org. On vulnerable
66
+ // code this creates a Business user INSIDE the victim org that no one invited.
67
+ // ==================================================================================
68
+ const email1 = `security+reg-inject-${Date.now()}@wearehackerone.com`
69
+
70
+ const before = await sdk.api.users.getOne({ email: email1 }).catch(() => null)
71
+ assert(before === null, `precondition failed: ${email1} already present in victim org`, 'precondition: victim org has no such user')
72
+
73
+ const res1 = await post('/v1/users/register', { email: email1, password, termsVersion, businessId: victimBusinessId })
74
+ console.log(` register(businessId=victim) -> HTTP ${res1.status}`)
75
+
76
+ const planted1: any = await sdk.api.users.getOne({ email: email1 }).catch(() => null)
77
+ const injectedIntoVictimOrg = !!planted1 && planted1.businessId === victimBusinessId
78
+
79
+ // Clean up BEFORE asserting (a failed assert calls process.exit(1) and would skip cleanup).
80
+ if (planted1?.id) await sdk.api.users.deleteOne(planted1.id).catch(() => {})
81
+
82
+ assert(
83
+ !injectedIntoVictimOrg,
84
+ `❌ EXPLOIT CONFIRMED: anonymous POST /v1/users/register with businessId=${victimBusinessId} created user `
85
+ + `${email1} (accountType=${planted1?.accountType}) inside the victim org — no invite required`,
86
+ '✅ register ignores caller-supplied businessId — no user injected into victim org',
87
+ )
88
+
89
+ await wait(undefined, 1200) // register endpoint rate-limits ~1/sec per IP
90
+
91
+ // ==================================================================================
92
+ // Scenario 2 — cross-org duplicate for the same email
93
+ // First register with NO businessId (the legitimate "root register, no org" record —
94
+ // the "oldest" account in the report). Then register the SAME email WITH the victim
95
+ // businessId. On vulnerable code the scoped duplicate check misses the org-less record,
96
+ // so a SECOND user record for the same email is created inside the victim org.
97
+ // ==================================================================================
98
+ const email2 = `security+reg-dup-${Date.now()}@wearehackerone.com`
99
+
100
+ const resRoot = await post('/v1/users/register', { email: email2, password, termsVersion })
101
+ console.log(` register(no businessId) -> HTTP ${resRoot.status}`)
102
+
103
+ await wait(undefined, 1200)
104
+
105
+ const resDup = await post('/v1/users/register', { email: email2, password, termsVersion, businessId: victimBusinessId })
106
+ console.log(` register(same email, businessId=victim) -> HTTP ${resDup.status}`)
107
+
108
+ const planted2: any = await sdk.api.users.getOne({ email: email2 }).catch(() => null)
109
+ const duplicateInVictimOrg = !!planted2 && planted2.businessId === victimBusinessId
110
+
111
+ if (planted2?.id) await sdk.api.users.deleteOne(planted2.id).catch(() => {})
112
+
113
+ assert(
114
+ !duplicateInVictimOrg,
115
+ `❌ EXPLOIT CONFIRMED: same email ${email2} registered into a second org — duplicate user `
116
+ + `(accountType=${planted2?.accountType}) created in victim org ${victimBusinessId}`,
117
+ '✅ register enforces global email uniqueness — no cross-org duplicate created',
118
+ )
119
+ }
120
+
121
+ // Allow running this test file independently
122
+ if (require.main === module) {
123
+ const sdk = new Session({ host })
124
+ const sdkNonAdmin = new Session({ host })
125
+
126
+ const runTests = async () => {
127
+ await setup_tests(sdk, sdkNonAdmin)
128
+ await register_business_id_injection_tests({ sdk, sdkNonAdmin })
129
+ }
130
+
131
+ runTests()
132
+ .then(() => {
133
+ console.log("✅ register businessId injection test suite completed successfully")
134
+ process.exit(0)
135
+ })
136
+ .catch((error) => {
137
+ console.error("❌ register businessId injection test suite failed:", error)
138
+ process.exit(1)
139
+ })
140
+ }
@@ -53,6 +53,7 @@ import { appointment_rescheduled_trigger_tests } from "./api_tests/appointment_r
53
53
  import { appointment_no_showed_trigger_tests } from "./api_tests/appointment_no_showed_trigger.test"
54
54
  import { group_event_attendee_status_triggers_tests } from "./api_tests/group_event_attendee_status_triggers.test"
55
55
  import { journey_error_branching_tests } from "./api_tests/journey_error_branching.test"
56
+ import { journey_delete_cancels_actions_tests } from "./api_tests/journey_delete_cancels_actions.test"
56
57
  import { afteraction_day_of_month_delay_tests } from "./api_tests/afteraction_day_of_month_delay.test"
57
58
  import { push_forms_to_portal_group_completion_tests } from "./api_tests/push_forms_to_portal_group_completion.test"
58
59
  import { setup_tests } from "./setup"
@@ -102,6 +103,7 @@ import { ai_conversations_rbac_tests } from "./api_tests/security/F-0005-ai-conv
102
103
  import { cascade_role_rename_cross_tenant_tests } from "./api_tests/security/F-0053-cascade-role-rename-cross-tenant.test";
103
104
  import { self_admin_role_assignment_tests } from "./api_tests/security/F-0076-self-admin-role-assignment.test";
104
105
  import { invite_user_enumeration_tests } from "./api_tests/security/F-0007-invite-user-enumeration.test";
106
+ import { register_business_id_injection_tests } from "./api_tests/security/register_business_id_injection.test";
105
107
  import { handle_incoming_communication_cross_tenant_tests } from "./api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test";
106
108
  import { sanitize_user_html_xss_tests } from "./api_tests/security/F-0013-sanitize-user-html.test";
107
109
  import { prototype_pollution_tests } from "./api_tests/security/F-0016-prototype-pollution.test";
@@ -15036,6 +15038,8 @@ const ip_address_form_tests = async () => {
15036
15038
  await replace_form_field_template_values_tests()
15037
15039
  await mfa_tests()
15038
15040
  await setup_tests(sdk, sdkNonAdmin)
15041
+ await custom_dashboards_tests({ sdk, sdkNonAdmin })
15042
+ await register_business_id_injection_tests({ sdk, sdkNonAdmin })
15039
15043
  await automation_trigger_tests()
15040
15044
  await resource_access_tags_tests({ sdk, sdkNonAdmin })
15041
15045
  await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
@@ -15088,7 +15092,6 @@ const ip_address_form_tests = async () => {
15088
15092
  await calendar_event_limits_tests({ sdk, sdkNonAdmin })
15089
15093
  await get_some_projection_tests({ sdk, sdkNonAdmin })
15090
15094
  await elation_user_id_tests({ sdk, sdkNonAdmin })
15091
- await custom_dashboards_tests({ sdk, sdkNonAdmin })
15092
15095
  await concurrent_build_threads_tests({ sdk, sdkNonAdmin })
15093
15096
  await custom_aggregation_tests({ sdk, sdkNonAdmin })
15094
15097
  await no_access_permission_checks_tests({ sdk, sdkNonAdmin })
@@ -15111,6 +15114,7 @@ const ip_address_form_tests = async () => {
15111
15114
  await test_ticket_automation_assignment_and_optimization()
15112
15115
  await monthly_availability_restrictions_tests({ sdk, sdkNonAdmin })
15113
15116
  await journey_error_branching_tests({ sdk, sdkNonAdmin })
15117
+ await journey_delete_cancels_actions_tests({ sdk, sdkNonAdmin })
15114
15118
  await message_assignment_trigger_tests({ sdk })
15115
15119
  await inbox_threads_building_tests()
15116
15120
  await inbox_threads_loading_tests()
Binary file