@tellescope/sdk 1.256.12 → 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 (114) hide show
  1. package/lib/cjs/sdk.d.ts +1 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts +20 -0
  6. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
  8. package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  10. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
  12. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
  16. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  18. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  19. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
  20. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  21. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  22. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
  24. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  26. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
  28. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  29. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  30. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  31. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
  32. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  33. package/lib/cjs/tests/setup_isolated.d.ts +68 -0
  34. package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
  35. package/lib/cjs/tests/setup_isolated.js +232 -0
  36. package/lib/cjs/tests/setup_isolated.js.map +1 -0
  37. package/lib/cjs/tests/tests.d.ts.map +1 -1
  38. package/lib/cjs/tests/tests.js +260 -208
  39. package/lib/cjs/tests/tests.js.map +1 -1
  40. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  41. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  42. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
  43. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  44. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  45. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  46. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
  47. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  48. package/lib/esm/enduser.d.ts +1 -0
  49. package/lib/esm/enduser.d.ts.map +1 -1
  50. package/lib/esm/sdk.d.ts +4 -2
  51. package/lib/esm/sdk.d.ts.map +1 -1
  52. package/lib/esm/sdk.js +1 -0
  53. package/lib/esm/sdk.js.map +1 -1
  54. package/lib/esm/session.d.ts +1 -0
  55. package/lib/esm/session.d.ts.map +1 -1
  56. package/lib/esm/tests/api_tests/organization_signup.test.d.ts +20 -0
  57. package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  58. package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
  59. package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
  60. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  61. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  62. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
  63. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  64. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  65. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  66. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
  67. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  68. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  69. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  70. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
  71. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  72. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  73. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  74. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
  75. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  76. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  77. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
  79. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  80. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  81. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  82. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
  83. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  84. package/lib/esm/tests/setup_isolated.d.ts +68 -0
  85. package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
  86. package/lib/esm/tests/setup_isolated.js +221 -0
  87. package/lib/esm/tests/setup_isolated.js.map +1 -0
  88. package/lib/esm/tests/tests.d.ts.map +1 -1
  89. package/lib/esm/tests/tests.js +260 -208
  90. package/lib/esm/tests/tests.js.map +1 -1
  91. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  92. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  93. package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
  94. package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  95. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  96. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  97. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
  98. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  99. package/lib/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +10 -10
  101. package/src/sdk.ts +4 -0
  102. package/src/tests/api_tests/organization_signup.test.ts +240 -0
  103. package/src/tests/api_tests/out_of_office_endpoint.test.ts +162 -0
  104. package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
  105. package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
  106. package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
  107. package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
  108. package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
  109. package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
  110. package/src/tests/setup_isolated.ts +170 -0
  111. package/src/tests/tests.ts +27 -0
  112. package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
  113. package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -0
  114. package/test_generated.pdf +0 -0
@@ -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
+ }
@@ -103,6 +103,7 @@ import { outbound_chat_sent_trigger_tests } from "./api_tests/outbound_chat_sent
103
103
  import { time_tracks_tests, time_tracks_historical_tests, time_tracks_correction_tests, time_tracks_review_tests, time_tracks_lock_tests, time_tracks_edge_case_tests, time_tracks_resubmit_tests, time_tracks_appointment_duration_tests } from "./api_tests/time_tracks.test";
104
104
  import { monthly_availability_restrictions_tests } from "./api_tests/monthly_availability_restrictions.test";
105
105
  import { calendar_event_limits_tests } from "./api_tests/calendar_event_limits.test";
106
+ import { out_of_office_endpoint_tests } from "./api_tests/out_of_office_endpoint.test";
106
107
  import { appointment_link_expiration_tests } from "./api_tests/appointment_link_expiration.test";
107
108
  import { custom_aggregation_tests } from "./api_tests/custom_aggregation.test";
108
109
  import { chats_analytics_tests } from "./api_tests/chats_analytics.test";
@@ -134,6 +135,10 @@ import { proxy_image_ssrf_tests } from "./api_tests/security/F-0025-proxy-image-
134
135
  import { webhook_outbound_ssrf_tests } from "./api_tests/security/F-0015-webhook-outbound-ssrf.test";
135
136
  import { public_endpoint_auth_field_lock_tests } from "./api_tests/security/public_endpoint_auth_field_lock.test";
136
137
  import { formsort_webhook_auth_field_lock_tests } from "./api_tests/security/formsort_webhook_auth_field_lock.test";
138
+ import { booking_info_idor_tests } from "./api_tests/security/F-0179-booking-info-idor-token-mint.test";
139
+ import { automated_actions_process_gate_tests } from "./api_tests/security/F-0192-automated-actions-process-gate.test";
140
+ import { schema_adminonly_enforcement_tests } from "./api_tests/security/F-0191-schema-adminonly-enforcement.test";
141
+ import { startlinktoken_escalation_tests } from "./api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test";
137
142
  import { bulk_assignment_tests } from "./api_tests/bulk_assignment.test";
138
143
  import { tickets_bulk_assign_care_team_tests } from "./api_tests/tickets_bulk_assign_care_team.test";
139
144
  import { managed_content_enduser_access_tests } from "./api_tests/managed_content_enduser_access.test";
@@ -151,6 +156,12 @@ import { dont_sync_to_elation_form_submission_tests } from "./api_tests/dont_syn
151
156
  import { medication_added_trigger_tests } from "./api_tests/medication_added_trigger.test";
152
157
  import { conditional_logic_medication_unit_tests } from "./unit_tests/conditional_logic_medication.test";
153
158
  import { phone_validation_unit_tests } from "./unit_tests/phone_validation.test";
159
+ import { openapi_spec_freshness_unit_tests } from "./unit_tests/openapi_spec_freshness.test";
160
+ import { do_not_send_address_unit_tests } from "./unit_tests/do_not_send_address.test";
161
+ import { organization_signup_checks } from "./api_tests/organization_signup.test";
162
+ import { add_to_journey_foreign_enduserid_checks } from "./api_tests/security/F-0196-add-to-journey-foreign-enduserid.test";
163
+ import { tickets_bulk_assign_from_queue_checks } from "./api_tests/tickets_bulk_assign_from_queue.test";
164
+ import { run_isolated_in_parallel } from "./setup_isolated";
154
165
  import { elation_user_id_tests } from "./api_tests/elation_user_id.test";
155
166
  import { organization_settings_duplicates_tests } from "./api_tests/organization_settings_duplicates.test";
156
167
  import { stedi_approval_tests } from "./api_tests/stedi_approval.test";
@@ -15362,11 +15373,22 @@ const ip_address_form_tests = async () => {
15362
15373
  await enduser_conditional_logic_tests()
15363
15374
  await conditional_logic_medication_unit_tests()
15364
15375
  await phone_validation_unit_tests()
15376
+ await openapi_spec_freshness_unit_tests()
15377
+ await do_not_send_address_unit_tests()
15365
15378
  await replace_enduser_template_values_tests()
15366
15379
  await safe_json_parse_template_tests()
15367
15380
  await replace_form_field_template_values_tests()
15368
15381
  await mfa_tests()
15369
15382
  await setup_tests(sdk, sdkNonAdmin)
15383
+ // Isolated, tenant-provisioned suites run first and CONCURRENTLY: each provisions its own
15384
+ // throwaway tenant via /test-support, so they cannot collide with each other or with the
15385
+ // shared fixture. Both collect Check[] and tear down before anything asserts — assert calls
15386
+ // process.exit, which would otherwise strand the other suite's tenants.
15387
+ await run_isolated_in_parallel([
15388
+ { name: 'Organization signup', run: organization_signup_checks },
15389
+ { name: 'F-0196 add_to_journey foreign enduserId', run: add_to_journey_foreign_enduserid_checks },
15390
+ { name: 'Tickets bulk assign from queue', run: tickets_bulk_assign_from_queue_checks },
15391
+ ])
15370
15392
  await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
15371
15393
  await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
15372
15394
  await webhook_outbound_ssrf_tests({ sdk, sdkNonAdmin })
@@ -15386,6 +15408,10 @@ const ip_address_form_tests = async () => {
15386
15408
  await enduser_write_restrictions_tests({ sdk, sdkNonAdmin })
15387
15409
  await enduser_ai_summary_trust_gate_tests({ sdk, sdkNonAdmin })
15388
15410
  await enduser_cross_create_tests({ sdk, sdkNonAdmin })
15411
+ await booking_info_idor_tests({ sdk, sdkNonAdmin })
15412
+ await automated_actions_process_gate_tests({ sdk, sdkNonAdmin })
15413
+ await schema_adminonly_enforcement_tests({ sdk, sdkNonAdmin })
15414
+ await startlinktoken_escalation_tests({ sdk, sdkNonAdmin })
15389
15415
  await globalunique_leak_tests({ sdk, sdkNonAdmin })
15390
15416
  await load_inbox_redaction_tests({ sdk, sdkNonAdmin })
15391
15417
  await suggested_reply_redaction_tests({ sdk, sdkNonAdmin })
@@ -15446,6 +15472,7 @@ const ip_address_form_tests = async () => {
15446
15472
  await time_tracks_resubmit_tests({ sdk, sdkNonAdmin })
15447
15473
  await time_tracks_appointment_duration_tests({ sdk, sdkNonAdmin })
15448
15474
  await calendar_event_limits_tests({ sdk, sdkNonAdmin })
15475
+ await out_of_office_endpoint_tests({ sdk, sdkNonAdmin })
15449
15476
  await appointment_link_expiration_tests({ sdk, sdkNonAdmin })
15450
15477
  await get_some_projection_tests({ sdk, sdkNonAdmin })
15451
15478
  await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
@@ -0,0 +1,60 @@
1
+ require('source-map-support').install();
2
+
3
+ import {
4
+ assert,
5
+ log_header,
6
+ } from "@tellescope/testing"
7
+
8
+ /**
9
+ * The email-suppression predicate from packages/private/api/api/modules/sqs.ts.
10
+ *
11
+ * Mirrored here rather than imported: the API is a private package the SDK does not depend on. The
12
+ * point of the test is to pin the CONTRACT — prefix AND domain both required — because the guard is
13
+ * otherwise unobservable from a test (a suppressed message simply never reaches SQS), and because
14
+ * loosening it in either direction would start suppressing real mail or start sending test mail.
15
+ *
16
+ * If this drifts from sqs.ts, that is the signal to look at why.
17
+ */
18
+ const is_do_not_send_address = (emailAddress?: string) => (
19
+ !!emailAddress && emailAddress.endsWith('@tellescope.com') && emailAddress.startsWith('dontsend')
20
+ )
21
+
22
+ export const do_not_send_address_unit_tests = async () => {
23
+ log_header("Do-not-send address predicate")
24
+
25
+ const cases: [string | undefined, boolean, string][] = [
26
+ ['dontsend@tellescope.com', true, 'exact prefix and domain'],
27
+ ['dontsend-signup-ab12@tellescope.com', true, 'suffixed local part (what provisioning generates)'],
28
+ ['dontsend3@tellescope.com', true, 'numbered variant already used by the suite'],
29
+
30
+ // near misses — both halves are load-bearing
31
+ ['dontsend@example.com', false, 'right prefix, wrong domain: must NOT suppress'],
32
+ ['realuser@tellescope.com', false, 'right domain, wrong prefix: must NOT suppress'],
33
+ ['notdontsend@tellescope.com', false, 'prefix must be at the START, not anywhere'],
34
+ ['dontsend@tellescope.com.evil.com', false, 'domain must be a suffix, not a substring'],
35
+
36
+ [undefined, false, 'undefined is not suppressed (and must not throw)'],
37
+ ['', false, 'empty string is not suppressed'],
38
+ ]
39
+
40
+ for (const [input, expected, why] of cases) {
41
+ assert(
42
+ is_do_not_send_address(input) === expected,
43
+ `${JSON.stringify(input)} should be ${expected ? 'suppressed' : 'sent'} — ${why}`,
44
+ `${JSON.stringify(input)} -> ${expected ? 'suppressed' : 'sent'} (${why})`,
45
+ )
46
+ }
47
+ }
48
+
49
+ // Allow running this test file independently
50
+ if (require.main === module) {
51
+ do_not_send_address_unit_tests()
52
+ .then(() => {
53
+ console.log("✅ Do-not-send address predicate tests completed successfully")
54
+ process.exit(0)
55
+ })
56
+ .catch((error) => {
57
+ console.error("❌ Do-not-send address predicate tests failed:", error)
58
+ process.exit(1)
59
+ })
60
+ }
@@ -0,0 +1,88 @@
1
+ require('source-map-support').install();
2
+
3
+ import * as fs from "fs"
4
+ import * as os from "os"
5
+ import * as path from "path"
6
+ import { execFileSync } from "child_process"
7
+
8
+ import {
9
+ assert,
10
+ log_header,
11
+ } from "@tellescope/testing"
12
+
13
+ /**
14
+ * Guard: packages/public/schema/openapi.json must match what the generator currently produces.
15
+ *
16
+ * The spec is git-tracked AND published inside @tellescope/schema (no `files` field, and the
17
+ * .npmignore covers only set_vars.sh), but nothing in the build or CI regenerates it. It had
18
+ * therefore drifted several commits behind schema.ts — which mattered because `adminOnly` is
19
+ * rendered into the spec as an "**Admin only.**" description prefix, so the published docs were
20
+ * advertising gates that had changed. That drift is the doc half of F-0191; the runtime half was
21
+ * fixed by making getCustomEndpointFields fall back to the schema flag.
22
+ *
23
+ * The generator is byte-deterministic (date `getExample`s are pinned in validation.ts precisely so
24
+ * this comparison is possible), so an exact match is a fair assertion.
25
+ *
26
+ * If this fails: cd packages/public/schema && node lib/cjs/generate-openapi.js ./openapi.json
27
+ */
28
+ export const openapi_spec_freshness_unit_tests = async () => {
29
+ log_header("OpenAPI spec freshness")
30
+
31
+ // resolve the schema package from its manifest, so this works regardless of hoisting
32
+ const schemaPkgJson = require.resolve('@tellescope/schema/package.json')
33
+ const schemaRoot = path.dirname(schemaPkgJson)
34
+ const generator = path.join(schemaRoot, 'lib', 'cjs', 'generate-openapi.js')
35
+ const committed = path.join(schemaRoot, 'openapi.json')
36
+
37
+ // Don't fail the suite for a stale/absent build — that is a build problem, not spec drift.
38
+ if (!fs.existsSync(generator)) {
39
+ console.log(` SKIPPED: generator not built at ${generator} (run ./build_cjs.sh)`)
40
+ return
41
+ }
42
+ assert(fs.existsSync(committed), `openapi.json missing at ${committed}`, 'openapi.json is present')
43
+
44
+ const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-freshness-')), 'openapi.json')
45
+ try {
46
+ execFileSync('node', [generator, tmp], { stdio: 'pipe' })
47
+
48
+ const regenerated = fs.readFileSync(tmp, 'utf8')
49
+ const onDisk = fs.readFileSync(committed, 'utf8')
50
+
51
+ if (regenerated !== onDisk) {
52
+ // a line count is far more useful than a 6000-line dump
53
+ const a = regenerated.split('\n')
54
+ const b = onDisk.split('\n')
55
+ const firstDiff = a.findIndex((line, i) => line !== b[i])
56
+ console.log(
57
+ ` regenerated ${a.length} lines vs committed ${b.length} lines;`
58
+ + ` first difference at line ${firstDiff + 1}`
59
+ )
60
+ if (firstDiff >= 0) {
61
+ console.log(` committed: ${(b[firstDiff] ?? '<missing>').trim().slice(0, 160)}`)
62
+ console.log(` regenerated: ${(a[firstDiff] ?? '<missing>').trim().slice(0, 160)}`)
63
+ }
64
+ }
65
+
66
+ assert(
67
+ regenerated === onDisk,
68
+ 'openapi.json is stale — regenerate it: '
69
+ + 'cd packages/public/schema && node lib/cjs/generate-openapi.js ./openapi.json',
70
+ 'openapi.json matches the generator output',
71
+ )
72
+ } finally {
73
+ try { fs.rmSync(path.dirname(tmp), { recursive: true, force: true }) } catch {}
74
+ }
75
+ }
76
+
77
+ // Allow running this test file independently
78
+ if (require.main === module) {
79
+ openapi_spec_freshness_unit_tests()
80
+ .then(() => {
81
+ console.log("✅ OpenAPI spec freshness test completed successfully")
82
+ process.exit(0)
83
+ })
84
+ .catch((error) => {
85
+ console.error("❌ OpenAPI spec freshness test failed:", error)
86
+ process.exit(1)
87
+ })
88
+ }
Binary file