@tellescope/sdk 1.256.13 → 1.256.14

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 (106) hide show
  1. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts +20 -0
  2. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
  4. package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  6. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
  8. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
  12. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  14. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
  16. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  18. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  19. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
  20. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  21. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  22. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
  24. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  26. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
  28. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  29. package/lib/cjs/tests/setup_isolated.d.ts +68 -0
  30. package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
  31. package/lib/cjs/tests/setup_isolated.js +232 -0
  32. package/lib/cjs/tests/setup_isolated.js.map +1 -0
  33. package/lib/cjs/tests/tests.d.ts.map +1 -1
  34. package/lib/cjs/tests/tests.js +257 -209
  35. package/lib/cjs/tests/tests.js.map +1 -1
  36. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  37. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  38. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
  39. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  40. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  41. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  42. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
  43. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  44. package/lib/esm/enduser.d.ts +1 -0
  45. package/lib/esm/enduser.d.ts.map +1 -1
  46. package/lib/esm/sdk.d.ts +3 -2
  47. package/lib/esm/sdk.d.ts.map +1 -1
  48. package/lib/esm/session.d.ts +1 -0
  49. package/lib/esm/session.d.ts.map +1 -1
  50. package/lib/esm/tests/api_tests/organization_signup.test.d.ts +20 -0
  51. package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  52. package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
  53. package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
  54. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  55. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  56. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
  57. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  58. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  59. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  60. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
  61. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  62. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  63. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  64. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
  65. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  66. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  67. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  68. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
  69. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  70. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  71. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  72. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
  73. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  74. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  75. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  76. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
  77. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  78. package/lib/esm/tests/setup_isolated.d.ts +68 -0
  79. package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
  80. package/lib/esm/tests/setup_isolated.js +221 -0
  81. package/lib/esm/tests/setup_isolated.js.map +1 -0
  82. package/lib/esm/tests/tests.d.ts.map +1 -1
  83. package/lib/esm/tests/tests.js +257 -209
  84. package/lib/esm/tests/tests.js.map +1 -1
  85. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  86. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  87. package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
  88. package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  89. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  90. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  91. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
  92. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  93. package/lib/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +10 -10
  95. package/src/tests/api_tests/organization_signup.test.ts +240 -0
  96. package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
  97. package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
  98. package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
  99. package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
  100. package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
  101. package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
  102. package/src/tests/setup_isolated.ts +170 -0
  103. package/src/tests/tests.ts +25 -0
  104. package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
  105. package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -0
  106. package/test_generated.pdf +0 -0
@@ -0,0 +1,208 @@
1
+ require('source-map-support').install();
2
+
3
+ import {
4
+ assert_checks,
5
+ Check,
6
+ setup_isolated_tenant,
7
+ } from "../../setup_isolated"
8
+
9
+ const host = process.env.API_URL || 'http://localhost:8080' as const
10
+
11
+ const RAND = () => Math.random().toString(36).slice(2, 10)
12
+
13
+ // far enough out that a locally running worker never claims the rows we inspect
14
+ const FUTURE_START = () => new Date(Date.now() + 60 * 60 * 1000)
15
+
16
+ /**
17
+ * Regression test for F-0196
18
+ * (security-audit/findings/F-0196-add-to-journey-plants-foreign-enduserid.md).
19
+ *
20
+ * `endusers.add_to_journey` inserted an `automated_actions` row for EACH caller-supplied `enduserId`
21
+ * with only mongoId-shape validation and no tenant-membership check. The preceding
22
+ * `DB.endusers.updateMany` is scoped but its result was discarded, and
23
+ * `DB.automated_actions.insertMany` is a direct write that bypasses the `ensureValidDependentKeys`
24
+ * foreign-key check the generic CRUD route gets. The resulting row is stamped
25
+ * `businessId: <caller's tenant>` while carrying `enduserId: <a victim in another tenant>`.
26
+ *
27
+ * The worker then dereferences that enduser with the raw driver and no `businessId`
28
+ * (worker/modules/automations.ts, the F-0074/F-0111/F-0159 family), so the victim's record loads
29
+ * under the attacker's automation: `pushFormsToPortal` copies the victim's PHI into the attacker's
30
+ * `form_responses` (readable by their staff) and `iterableCustomEvent` writes an attacker-authored
31
+ * note into the victim's chart. F-0196 is the reachability that made those three exploitable.
32
+ *
33
+ * There are TWO independent plant sites and a fix to only one is a half fix:
34
+ * - inline branch, taken when `automationStepId` is supplied (api.ts add_to_journey handler)
35
+ * - module branch, taken when it is not: `handle_automation_event`'s
36
+ * `validEnduserIds = conditionsQuery ? <scoped find> : event.enduserIds` passes caller ids
37
+ * straight through for a step with NO conditions.
38
+ * Both are covered below (A1/A2).
39
+ *
40
+ * Design notes:
41
+ * - Two provisioned tenants, so the victim is a real foreign enduser and we can also assert their
42
+ * record was not mutated. Both must come from provisioning: `add_to_journey` is
43
+ * `requiresSubscription: true`, and a `create_and_join` org is born with an expired subscription.
44
+ * - The steps use `setEnduserStatus` — no vendor, no Bedrock — and `startAt` is an hour out so a
45
+ * running worker cannot process the rows before we read them.
46
+ * - `throttle` is deliberately omitted: it is the only way add_to_journey's per-pair rate limit can
47
+ * throw.
48
+ * - Everything is observable through `automated_actions` reads, so the worker is never needed.
49
+ *
50
+ * RED (pre-fix): A1, A2 and B2 fail — the victim's id appears in the attacker tenant's
51
+ * automated_actions. GREEN (post-fix): the foreign id is silently dropped and the caller's own
52
+ * endusers still enroll normally.
53
+ */
54
+ export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]> => {
55
+ const checks: Check[] = []
56
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
57
+
58
+ let attacker: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
59
+ let victimTenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
60
+
61
+ try {
62
+ /* ================= fixtures: two isolated tenants ================= */
63
+
64
+ ;[attacker, victimTenant] = await Promise.all([
65
+ setup_isolated_tenant({ name: `F0196 attacker ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 }),
66
+ setup_isolated_tenant({ name: `F0196 victim ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 }),
67
+ ])
68
+
69
+ const victim = await victimTenant.sdk.api.endusers.createOne({ fname: 'F0196', lname: 'Victim' })
70
+ const own = await attacker.sdk.api.endusers.createOne({ fname: 'F0196', lname: 'OwnPatient' })
71
+
72
+ record(
73
+ 'setup: two separate tenants, victim belongs to the other one',
74
+ attacker.businessId !== victimTenant.businessId && !!victim.id && !!own.id,
75
+ `attacker=${attacker.businessId} victim tenant=${victimTenant.businessId} victimEnduser=${victim.id}`,
76
+ )
77
+
78
+ // one journey + onJourneyStart step per branch, so the per-(enduser,journey) throttle bucket and
79
+ // the row sets stay independent
80
+ const makeJourney = async (title: string) => {
81
+ const journey = await attacker!.sdk.api.journeys.createOne({ title: `${title} ${RAND()}` })
82
+ const step = await attacker!.sdk.api.automation_steps.createOne({
83
+ journeyId: journey.id,
84
+ // benign and worker-local: no vendor, no Bedrock. NO `conditions`, which is precisely the
85
+ // vulnerable branch in handle_automation_event.
86
+ action: { type: 'setEnduserStatus', info: { status: 'F0196' } },
87
+ events: [{ type: 'onJourneyStart', info: {} }],
88
+ } as any)
89
+ return { journey, step }
90
+ }
91
+
92
+ const inline = await makeJourney('F0196 inline')
93
+ const modulePath = await makeJourney('F0196 module')
94
+ const mixed = await makeJourney('F0196 mixed')
95
+
96
+ /** every automated_actions row the attacker tenant can see for a journey */
97
+ const rowsFor = async (journeyId: string) => (
98
+ await attacker!.sdk.api.automated_actions.getSome({ filter: { journeyId } } as any)
99
+ )
100
+ const enduserIdsFor = async (journeyId: string) => (await rowsFor(journeyId)).map((r: any) => r.enduserId)
101
+
102
+ /* ===== A: the foreign id must never be planted, on EITHER branch ===== */
103
+
104
+ // A1 — inline branch: automationStepId supplied
105
+ const inlineResult = await attacker.sdk.api.endusers.add_to_journey({
106
+ enduserIds: [victim.id],
107
+ journeyId: inline.journey.id,
108
+ automationStepId: inline.step.id,
109
+ startAt: FUTURE_START(),
110
+ } as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
111
+
112
+ const inlineIds = await enduserIdsFor(inline.journey.id)
113
+ record(
114
+ 'A1: inline branch (automationStepId) does not plant a foreign enduserId',
115
+ !inlineIds.includes(victim.id),
116
+ `call=${inlineResult}; automated_actions enduserIds=${JSON.stringify(inlineIds)} (must not contain ${victim.id})`,
117
+ )
118
+
119
+ // A2 — module branch: no automationStepId, so it routes through handle_automation_event
120
+ const moduleResult = await attacker.sdk.api.endusers.add_to_journey({
121
+ enduserIds: [victim.id],
122
+ journeyId: modulePath.journey.id,
123
+ startAt: FUTURE_START(),
124
+ } as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
125
+
126
+ const moduleIds = await enduserIdsFor(modulePath.journey.id)
127
+ record(
128
+ 'A2: module branch (no automationStepId) does not plant a foreign enduserId',
129
+ !moduleIds.includes(victim.id),
130
+ `call=${moduleResult}; automated_actions enduserIds=${JSON.stringify(moduleIds)} (must not contain ${victim.id})`,
131
+ )
132
+
133
+ // A3 — the victim's own record must be untouched (the scoped updateMany should already ensure it)
134
+ const victimAfter = await victimTenant.sdk.api.endusers.getOne(victim.id)
135
+ const victimJourneys = (victimAfter as any)?.journeys ?? {}
136
+ record(
137
+ 'A3: the victim enduser was not enrolled in the attacker\'s journey',
138
+ !victimJourneys[inline.journey.id] && !victimJourneys[modulePath.journey.id],
139
+ `victim journeys=${JSON.stringify(victimJourneys)}`,
140
+ )
141
+
142
+ /* ===== B: legitimate use must keep working ===== */
143
+
144
+ // B1 — positive control on both branches, so a blanket failure can't masquerade as a fix
145
+ await attacker.sdk.api.endusers.add_to_journey({
146
+ enduserIds: [own.id],
147
+ journeyId: inline.journey.id,
148
+ automationStepId: inline.step.id,
149
+ startAt: FUTURE_START(),
150
+ } as any)
151
+ await attacker.sdk.api.endusers.add_to_journey({
152
+ enduserIds: [own.id],
153
+ journeyId: modulePath.journey.id,
154
+ startAt: FUTURE_START(),
155
+ } as any)
156
+
157
+ const inlineOwn = await enduserIdsFor(inline.journey.id)
158
+ const moduleOwn = await enduserIdsFor(modulePath.journey.id)
159
+ record(
160
+ 'B1: the caller\'s OWN enduser still enrolls on both branches',
161
+ inlineOwn.includes(own.id) && moduleOwn.includes(own.id),
162
+ `inline=${JSON.stringify(inlineOwn)} module=${JSON.stringify(moduleOwn)} (both must contain ${own.id})`,
163
+ )
164
+
165
+ // B2 — a mixed list must enroll the visible id and drop only the foreign one: proves an
166
+ // intersect, not an all-or-nothing rejection
167
+ const mixedResult = await attacker.sdk.api.endusers.add_to_journey({
168
+ enduserIds: [own.id, victim.id],
169
+ journeyId: mixed.journey.id,
170
+ automationStepId: mixed.step.id,
171
+ startAt: FUTURE_START(),
172
+ } as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
173
+
174
+ const mixedIds = await enduserIdsFor(mixed.journey.id)
175
+ record(
176
+ 'B2: a mixed list enrolls the visible enduser and drops only the foreign one',
177
+ mixedIds.includes(own.id) && !mixedIds.includes(victim.id),
178
+ `call=${mixedResult}; enduserIds=${JSON.stringify(mixedIds)} (want ${own.id}, not ${victim.id})`,
179
+ )
180
+ } finally {
181
+ // Teardown happens here, BEFORE anything asserts — assert calls process.exit and would otherwise
182
+ // strand these tenants permanently (nothing can delete an organization record).
183
+ for (const t of [attacker, victimTenant]) {
184
+ if (t) { try { await t.teardown() } catch (e) { console.error('teardown failed', e) } }
185
+ }
186
+ }
187
+
188
+ return checks
189
+ }
190
+
191
+ /** Asserting wrapper, for standalone runs and for sequential use in the suite. */
192
+ export const add_to_journey_foreign_enduserid_tests = async () => {
193
+ assert_checks('F-0196 add_to_journey foreign enduserId', await add_to_journey_foreign_enduserid_checks())
194
+ }
195
+
196
+ // Allow running this test file independently
197
+ if (require.main === module) {
198
+ console.log(`🌐 Using API URL: ${host}`)
199
+ add_to_journey_foreign_enduserid_tests()
200
+ .then(() => {
201
+ console.log("✅ F-0196 add_to_journey foreign enduserId test suite completed successfully")
202
+ process.exit(0)
203
+ })
204
+ .catch((error) => {
205
+ console.error("❌ F-0196 add_to_journey foreign enduserId test suite failed:", error)
206
+ process.exit(1)
207
+ })
208
+ }
@@ -0,0 +1,279 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ assert_checks,
6
+ Check,
7
+ setup_isolated_tenant,
8
+ } from "../setup_isolated"
9
+
10
+ const host = process.env.API_URL || 'http://localhost:8080' as const
11
+
12
+ const RAND = () => Math.random().toString(36).slice(2, 10)
13
+
14
+ /**
15
+ * Coverage for `tickets.bulk_assign` when a `queueId` is supplied — the bulk equivalent of
16
+ * `tickets.assign_from_queue`, added so a Ticket Queue's tickets can be checkbox-selected and handed
17
+ * to one user in a single call instead of one `assign_from_queue` round trip per ticket.
18
+ *
19
+ * Without `queueId` the endpoint only does `$set: { owner }`. That is insufficient for a QUEUED
20
+ * ticket: it would stay in the queue (still pullable by someone else), carry no dequeue provenance,
21
+ * and leave `ticket_queues.count` drifting upward forever. The queue branch therefore also unsets
22
+ * `queueId`, sets `dequeuedAt`/`dequeuedFrom`, and decrements the queue's count — the same
23
+ * bookkeeping `assign_from_queue` does for a single ticket. A/B/C below pin each of those.
24
+ *
25
+ * D is the check that actually justifies the handler's shape. The tickets model declares
26
+ * `constraints.access: [{ type: 'filter', field: 'owner' }]`, which the DB layer turns into
27
+ * `owner === self OR creator === self` for any non-`All` access. A queued ticket has no owner, and
28
+ * was typically created by someone else, so a non-admin queue manager's `DB.tickets.updateMany`
29
+ * matches NOTHING — the call would return 200 having assigned zero tickets, silently. The handler
30
+ * uses `buildOrganizationWideQueries` to get past that, exactly as `assign_from_queue` does, and
31
+ * re-adds the queue-membership check that those queries bypass. D1 proves the escape hatch works for
32
+ * a legitimate queue member; D2 proves it is still gated, and is the check that fails if someone
33
+ * later drops the membership check while keeping the organization-wide queries.
34
+ *
35
+ * A provisioned non-admin gets `tickets: ASSIGNED_AND_DEFAULT_ACCESS` (read/update = 'Assigned'),
36
+ * which is what makes D meaningful. The D tickets deliberately have NO enduserId, so the 'Assigned'
37
+ * role filter's other branch (enduserId in assignedEnduserIds) cannot match either — the ONLY way
38
+ * they can be assigned is through the organization-wide path.
39
+ *
40
+ * E is the regression guard on the pre-existing no-`queueId` behavior, which must be unchanged.
41
+ *
42
+ * Cross-tenant (F) is covered because `buildOrganizationWideQueries` is bound to the caller's
43
+ * businessId: another tenant's queue is invisible even to an admin, who skips the membership check.
44
+ *
45
+ * `ticket_queues.count` is maintained by asynchronous side effects (increment on ticket create) and
46
+ * the decrement here is deliberately fire-and-forget, so every count read polls.
47
+ */
48
+
49
+ // count is maintained by async side effects; give it a moment to settle before reading
50
+ const poll_for = async <T,>(get: () => Promise<T>, done: (v: T) => boolean, attempts=20): Promise<T> => {
51
+ let value = await get()
52
+ for (let i = 0; i < attempts && !done(value); i++) {
53
+ await new Promise(resolve => setTimeout(resolve, 250))
54
+ value = await get()
55
+ }
56
+ return value
57
+ }
58
+
59
+ export const tickets_bulk_assign_from_queue_checks = async (): Promise<Check[]> => {
60
+ const checks: Check[] = []
61
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
62
+
63
+ let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
64
+ let otherTenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
65
+
66
+ try {
67
+ /* ================= fixtures ================= */
68
+
69
+ // users[1] joins the queue, users[2] deliberately does not (D2)
70
+ ;[tenant, otherTenant] = await Promise.all([
71
+ setup_isolated_tenant({
72
+ name: `BulkAssignQueue ${RAND()}`,
73
+ users: [{ isAdmin: true }, { roles: ['Non-Admin'] }, { roles: ['Non-Admin'] }],
74
+ apiKeys: 0,
75
+ }),
76
+ setup_isolated_tenant({
77
+ name: `BulkAssignQueue other ${RAND()}`,
78
+ users: [{ isAdmin: true }],
79
+ apiKeys: 0,
80
+ }),
81
+ ])
82
+
83
+ const { sdk, sdkNonAdmin } = tenant
84
+ const queueMember = tenant.users[1]
85
+ const outsider = tenant.users[2]
86
+
87
+ // a session for users[2] — setup_isolated_tenant only builds sessions for users[0] and users[1]
88
+ const sdkOutsider = new Session({ host, authToken: outsider.authToken })
89
+ await sdkOutsider.refresh_session()
90
+
91
+ const owner = await sdk.api.users.createOne({
92
+ email: `dontsend-bulk-assign-queue-${RAND()}@tellescope.com`,
93
+ fname: 'BulkAssign', lname: 'QueueOwner',
94
+ verifiedEmail: true,
95
+ })
96
+
97
+ const queue = await sdk.api.ticket_queues.createOne({
98
+ title: `Bulk Assign Queue ${RAND()}`,
99
+ userIds: [owner.id, queueMember.id], // outsider is NOT a member
100
+ })
101
+
102
+ const enduser = await sdk.api.endusers.createOne({ fname: 'BulkAssign', lname: 'QueuePatient' })
103
+
104
+ // A/B/C fixtures — one carries an enduser so care team behavior is exercised alongside dequeue
105
+ const queued1 = await sdk.api.tickets.createOne({ title: 'BAQ 1', enduserId: enduser.id, queueId: queue.id })
106
+ const queued2 = await sdk.api.tickets.createOne({ title: 'BAQ 2', queueId: queue.id })
107
+
108
+ const countBefore = await poll_for(
109
+ () => sdk.api.ticket_queues.getOne(queue.id).then(q => q.count ?? 0),
110
+ c => c === 2,
111
+ )
112
+ record(
113
+ 'setup: queue count reflects both queued tickets',
114
+ countBefore === 2,
115
+ `count=${countBefore} (want 2); queue=${queue.id}`,
116
+ )
117
+
118
+ /* ===== A: the dequeue bookkeeping ===== */
119
+
120
+ const assignResult = await sdk.api.tickets.bulk_assign({
121
+ ids: [queued1.id, queued2.id], userId: owner.id, queueId: queue.id, addToCareTeam: true,
122
+ })
123
+ record(
124
+ 'A1: reports both tickets assigned and none skipped',
125
+ assignResult.assigned === 2 && assignResult.skipped === 0,
126
+ `assigned=${assignResult.assigned} skipped=${assignResult.skipped} (want 2 / 0)`,
127
+ )
128
+
129
+ const after1 = await sdk.api.tickets.getOne(queued1.id)
130
+ record(
131
+ 'A2: ticket left the queue with owner and dequeue provenance set',
132
+ !after1.queueId && after1.owner === owner.id && !!after1.dequeuedAt && after1.dequeuedFrom === queue.id,
133
+ `queueId=${JSON.stringify(after1.queueId)} owner=${after1.owner} dequeuedAt=${JSON.stringify(after1.dequeuedAt)} dequeuedFrom=${after1.dequeuedFrom}`,
134
+ )
135
+
136
+ const countAfter = await poll_for(
137
+ () => sdk.api.ticket_queues.getOne(queue.id).then(q => q.count ?? 0),
138
+ c => c === 0,
139
+ )
140
+ record(
141
+ 'A3: queue count decremented by the number assigned',
142
+ countAfter === 0,
143
+ `count=${countAfter} (want 0, was ${countBefore})`,
144
+ )
145
+
146
+ record(
147
+ 'A4: addToCareTeam adds the new owner to the ticket enduser\'s care team',
148
+ !!(await sdk.api.endusers.getOne(enduser.id)).assignedTo?.includes(owner.id),
149
+ `assignedTo must contain ${owner.id}`,
150
+ )
151
+
152
+ /* ===== B: ids no longer in the queue are skipped, not re-assigned ===== */
153
+
154
+ const replayResult = await sdk.api.tickets.bulk_assign({
155
+ ids: [queued1.id, queued2.id], userId: owner.id, queueId: queue.id,
156
+ })
157
+ record(
158
+ 'B1: already-dequeued ids are reported as skipped',
159
+ replayResult.assigned === 0 && replayResult.skipped === 2,
160
+ `assigned=${replayResult.assigned} skipped=${replayResult.skipped} (want 0 / 2)`,
161
+ )
162
+
163
+ const countAfterReplay = await sdk.api.ticket_queues.getOne(queue.id).then(q => q.count ?? 0)
164
+ record(
165
+ 'B2: queue count is not decremented again when nothing was assigned',
166
+ countAfterReplay === 0,
167
+ `count=${countAfterReplay} (want 0 — a decrement here would drive it negative)`,
168
+ )
169
+
170
+ /* ===== C: the queue scope is honored ===== */
171
+
172
+ const otherQueue = await sdk.api.ticket_queues.createOne({
173
+ title: `Bulk Assign Other Queue ${RAND()}`,
174
+ userIds: [owner.id],
175
+ })
176
+ const inOtherQueue = await sdk.api.tickets.createOne({ title: 'BAQ other queue', queueId: otherQueue.id })
177
+
178
+ const wrongQueueResult = await sdk.api.tickets.bulk_assign({
179
+ ids: [inOtherQueue.id], userId: owner.id, queueId: queue.id,
180
+ })
181
+ const stillInOther = await sdk.api.tickets.getOne(inOtherQueue.id)
182
+ record(
183
+ 'C1: a ticket in a different queue is not assigned when the wrong queueId is passed',
184
+ wrongQueueResult.assigned === 0 && stillInOther.queueId === otherQueue.id && !stillInOther.owner,
185
+ `assigned=${wrongQueueResult.assigned} (want 0); ticket queueId=${stillInOther.queueId} owner=${JSON.stringify(stillInOther.owner)}`,
186
+ )
187
+
188
+ /* ===== D: the organization-wide path, and its gate ===== */
189
+
190
+ // NO enduserId: the 'Assigned' role filter cannot match these by owner, creator, or enduser,
191
+ // so they are assignable only through the organization-wide queries
192
+ const forMember1 = await sdk.api.tickets.createOne({ title: 'BAQ member 1', queueId: queue.id })
193
+ const forMember2 = await sdk.api.tickets.createOne({ title: 'BAQ member 2', queueId: queue.id })
194
+
195
+ const memberResult = await sdkNonAdmin.api.tickets.bulk_assign({
196
+ ids: [forMember1.id, forMember2.id], userId: owner.id, queueId: queue.id,
197
+ }).then(r => r).catch((e: any) => ({ error: e?.message || String(e) } as any))
198
+
199
+ const memberAfter = await sdk.api.tickets.getOne(forMember1.id)
200
+ record(
201
+ 'D1: a non-admin queue member can bulk assign queued tickets they do not own',
202
+ memberResult?.assigned === 2 && !memberAfter.queueId && memberAfter.owner === owner.id,
203
+ `result=${JSON.stringify(memberResult)}; ticket queueId=${JSON.stringify(memberAfter.queueId)} owner=${memberAfter.owner}`
204
+ + ` — 0 assigned here means the handler fell back to the owner-filtered DB`,
205
+ )
206
+
207
+ const forOutsider = await sdk.api.tickets.createOne({ title: 'BAQ outsider', queueId: queue.id })
208
+ const outsiderResult = await sdkOutsider.api.tickets.bulk_assign({
209
+ ids: [forOutsider.id], userId: outsider.id, queueId: queue.id,
210
+ }).then((r: any) => ({ resolved: r })).catch((e: any) => ({ rejected: e?.message || String(e) }))
211
+
212
+ const outsiderAfter = await sdk.api.tickets.getOne(forOutsider.id)
213
+ record(
214
+ 'D2: a non-admin who is not a member of the queue is rejected',
215
+ !!(outsiderResult as any).rejected && outsiderAfter.queueId === queue.id && !outsiderAfter.owner,
216
+ `result=${JSON.stringify(outsiderResult)}; ticket queueId=${outsiderAfter.queueId} owner=${JSON.stringify(outsiderAfter.owner)}`,
217
+ )
218
+
219
+ /* ===== E: regression guard — the no-queueId path is unchanged ===== */
220
+
221
+ const stayQueued = await sdk.api.tickets.createOne({ title: 'BAQ not dequeued', queueId: queue.id })
222
+ const legacyResult = await sdk.api.tickets.bulk_assign({ ids: [stayQueued.id], userId: owner.id })
223
+ const stayedAfter = await sdk.api.tickets.getOne(stayQueued.id)
224
+ record(
225
+ 'E1: without queueId the ticket is assigned but NOT dequeued',
226
+ legacyResult.assigned === 1 && stayedAfter.owner === owner.id
227
+ && stayedAfter.queueId === queue.id && !stayedAfter.dequeuedAt,
228
+ `assigned=${legacyResult.assigned}; owner=${stayedAfter.owner} queueId=${stayedAfter.queueId} dequeuedAt=${JSON.stringify(stayedAfter.dequeuedAt)}`,
229
+ )
230
+
231
+ // a ticket already owned by the target user is still "assigned", not counted as skipped
232
+ const repeatResult = await sdk.api.tickets.bulk_assign({ ids: [stayQueued.id], userId: owner.id })
233
+ record(
234
+ 'E2: re-assigning to the same owner is not reported as skipped',
235
+ repeatResult.assigned === 1 && repeatResult.skipped === 0,
236
+ `assigned=${repeatResult.assigned} skipped=${repeatResult.skipped} (want 1 / 0 — matchedCount, not modifiedCount)`,
237
+ )
238
+
239
+ /* ===== F: another tenant's queue is invisible, even to an admin ===== */
240
+
241
+ const foreignResult = await otherTenant.sdk.api.tickets.bulk_assign({
242
+ ids: [forOutsider.id], userId: otherTenant.users[0].id, queueId: queue.id,
243
+ }).then((r: any) => ({ resolved: r })).catch((e: any) => ({ rejected: e?.message || String(e) }))
244
+
245
+ const foreignAfter = await sdk.api.tickets.getOne(forOutsider.id)
246
+ record(
247
+ 'F1: another tenant cannot assign tickets out of this tenant\'s queue',
248
+ (foreignAfter.queueId === queue.id && !foreignAfter.owner)
249
+ && ((foreignResult as any).rejected || (foreignResult as any).resolved?.assigned === 0),
250
+ `result=${JSON.stringify(foreignResult)}; ticket queueId=${foreignAfter.queueId} owner=${JSON.stringify(foreignAfter.owner)}`,
251
+ )
252
+ } finally {
253
+ // Teardown BEFORE anything asserts — assert calls process.exit, which would strand these tenants.
254
+ for (const t of [tenant, otherTenant]) {
255
+ if (t) { try { await t.teardown() } catch (e) { console.error('teardown failed', e) } }
256
+ }
257
+ }
258
+
259
+ return checks
260
+ }
261
+
262
+ /** Asserting wrapper, for standalone runs and for sequential use in the suite. */
263
+ export const tickets_bulk_assign_from_queue_tests = async () => {
264
+ assert_checks('Tickets bulk assign from queue', await tickets_bulk_assign_from_queue_checks())
265
+ }
266
+
267
+ // Allow running this test file independently
268
+ if (require.main === module) {
269
+ console.log(`🌐 Using API URL: ${host}`)
270
+ tickets_bulk_assign_from_queue_tests()
271
+ .then(() => {
272
+ console.log("✅ Tickets bulk assign from queue test suite completed successfully")
273
+ process.exit(0)
274
+ })
275
+ .catch((error) => {
276
+ console.error("❌ Tickets bulk assign from queue test suite failed:", error)
277
+ process.exit(1)
278
+ })
279
+ }
@@ -0,0 +1,170 @@
1
+ import axios from "axios"
2
+
3
+ import { assert, log_header } from "@tellescope/testing"
4
+
5
+ import { Session } from "../sdk"
6
+
7
+ const host = process.env.API_URL || 'http://localhost:8080' as const
8
+
9
+ /**
10
+ * Provisions a throwaway tenant for a test, via the API's /test-support endpoints.
11
+ *
12
+ * Why this exists alongside setup.ts: setup_tests authenticates two hand-seeded users named by
13
+ * TEST_EMAIL / NON_ADMIN_EMAIL against a hardcoded businessId, so running the suite requires a copy
14
+ * of someone's local database, and every test shares one tenant. This helper needs **no environment
15
+ * configuration at all** against a local API — clone, empty Mongo, run.
16
+ *
17
+ * setup.ts is deliberately untouched; existing tests keep the shared org. Use this for new tests.
18
+ *
19
+ * Isolated tests must NOT call sdk.reset_db(): it clears six global throttle namespaces, so it
20
+ * disturbs the rate-limit state of any concurrently running suite even though record data is
21
+ * tenant-scoped.
22
+ */
23
+
24
+ const TEST_SUPPORT_HEADER = 'x-tellescope-test-support'
25
+
26
+ export type IsolatedUser = {
27
+ id: string,
28
+ email: string,
29
+ password: string,
30
+ authToken: string,
31
+ roles: string[],
32
+ }
33
+
34
+ export type IsolatedTenant = {
35
+ businessId: string,
36
+ subdomain: string,
37
+ users: IsolatedUser[],
38
+ apiKeys: string[],
39
+ roles: string[],
40
+ /** Admin session (users[0]) */
41
+ sdk: Session,
42
+ /** Non-admin session (users[1]), when the spec provisioned one */
43
+ sdkNonAdmin: Session,
44
+ teardown: () => Promise<void>,
45
+ }
46
+
47
+ export type IsolatedTenantSpec = {
48
+ name?: string,
49
+ users?: { roles?: string[], isAdmin?: boolean }[],
50
+ apiKeys?: number,
51
+ roles?: { role: string, permissions?: any }[],
52
+ }
53
+
54
+ const post = async (path: string, body: object) => {
55
+ const r = await axios.request({
56
+ method: 'post',
57
+ url: `${host}/test-support${path}`,
58
+ // forwarded only if present — required on staging, unnecessary (and unset) locally
59
+ data: { ...body, ...(process.env.SERVICES_SECRET ? { servicesSecret: process.env.SERVICES_SECRET } : {}) },
60
+ headers: { [TEST_SUPPORT_HEADER]: '1' },
61
+ validateStatus: () => true,
62
+ })
63
+
64
+ if (r.status !== 200) {
65
+ // Surface the server's reason. setup.ts's bare process.exit() on missing credentials turns a
66
+ // misconfiguration into a silent green pass; a provisioning failure must be loud.
67
+ throw new Error(
68
+ `test-support ${path} failed (${r.status}): ${JSON.stringify(r.data)}`
69
+ + (r.status === 404
70
+ ? `\nThe route is not mounted — LOCAL is probably not "true" in the API's environment.`
71
+ : '')
72
+ )
73
+ }
74
+ return r.data
75
+ }
76
+
77
+ export const setup_isolated_tenant = async (spec: IsolatedTenantSpec = {}): Promise<IsolatedTenant> => {
78
+ const tenant = await post('/provision-tenant', spec)
79
+
80
+ const sessionFor = async (user?: IsolatedUser) => {
81
+ if (!user) return new Session({ host })
82
+ const session = new Session({ host, authToken: user.authToken })
83
+ await session.refresh_session()
84
+ return session
85
+ }
86
+
87
+ const [sdk, sdkNonAdmin] = await Promise.all([
88
+ sessionFor(tenant.users[0]),
89
+ sessionFor(tenant.users[1]),
90
+ ])
91
+
92
+ return {
93
+ ...tenant,
94
+ sdk,
95
+ sdkNonAdmin,
96
+ teardown: async () => { await post('/teardown-tenant', { businessId: tenant.businessId }) },
97
+ }
98
+ }
99
+
100
+ /** Sets the pending email-verification code for an unverified user, so a test can drive the real
101
+ * submit_email_verification endpoint. The address must be a `dontsend...@tellescope.com` one. */
102
+ export const set_email_verification_code = async (email: string, code: string) => (
103
+ post('/set-email-verification-code', { email, code })
104
+ )
105
+
106
+ /** Raw access, for tests that need to assert on the response of a rejected call. */
107
+ export const test_support_request = async (path: string, body: object) => {
108
+ const r = await axios.request({
109
+ method: 'post',
110
+ url: `${host}/test-support${path}`,
111
+ data: { ...body, ...(process.env.SERVICES_SECRET ? { servicesSecret: process.env.SERVICES_SECRET } : {}) },
112
+ headers: { [TEST_SUPPORT_HEADER]: '1' },
113
+ validateStatus: () => true,
114
+ })
115
+ return { status: r.status, body: r.data }
116
+ }
117
+
118
+ /* ============================ isolated-test result plumbing ============================ */
119
+
120
+ export type Check = { label: string, pass: boolean, detail: string }
121
+
122
+ /** Prints a suite's evidence table, then asserts. Split out so the assert phase can be deferred until
123
+ * after every concurrent suite has torn down — see run_isolated_in_parallel. */
124
+ export const assert_checks = (name: string, checks: Check[]) => {
125
+ console.log(`\n ${name} check summary:`)
126
+ for (const c of checks) {
127
+ console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
128
+ }
129
+ console.log('')
130
+
131
+ for (const c of checks) {
132
+ assert(c.pass, `${c.label} — ${c.detail}`, c.label)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Runs isolated test bodies CONCURRENTLY.
138
+ *
139
+ * Why this exists rather than a bare Promise.all: the harness's `assert` calls process.exit(1) on
140
+ * failure, and process.exit does not run pending `finally` blocks. So if one suite asserted while
141
+ * another was still in flight, the other's teardown would never run and its provisioned tenants would
142
+ * be stranded permanently (nothing in the system can delete an organization record). `assert` also
143
+ * exits rather than throwing, so Promise.allSettled cannot recover it either.
144
+ *
145
+ * The contract that makes concurrency safe: each `run` collects Check[] and tears down its OWN tenants
146
+ * in a finally, and never asserts. Nothing here asserts until every suite has settled.
147
+ *
148
+ * Each suite's table prints as one non-yielding burst, so concurrent output stays readable.
149
+ */
150
+ export const run_isolated_in_parallel = async (
151
+ suites: { name: string, run: () => Promise<Check[]> }[],
152
+ ) => {
153
+ log_header(`Isolated (parallel): ${suites.map(s => s.name).join(' | ')}`)
154
+
155
+ const settled = await Promise.allSettled(suites.map(s => s.run()))
156
+
157
+ // Surface an infrastructure failure (a throw, not a failed check) before asserting anything, so the
158
+ // reason isn't buried under another suite's table.
159
+ const rejected = settled.flatMap((r, i) => r.status === 'rejected' ? [{ name: suites[i].name, reason: r.reason }] : [])
160
+ for (const { name, reason } of rejected) {
161
+ console.error(` ${name} threw before producing checks:`, reason)
162
+ }
163
+ if (rejected.length) throw rejected[0].reason
164
+
165
+ for (let i = 0; i < settled.length; i++) {
166
+ const r = settled[i]
167
+ if (r.status !== 'fulfilled') continue
168
+ assert_checks(suites[i].name, r.value)
169
+ }
170
+ }