@tellescope/sdk 1.255.3 → 1.255.5

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 (36) hide show
  1. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +316 -0
  4. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/medication_added_trigger.test.js +206 -136
  7. package/lib/cjs/tests/api_tests/medication_added_trigger.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +10 -0
  9. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -1
  10. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +184 -41
  11. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -1
  12. package/lib/cjs/tests/tests.d.ts.map +1 -1
  13. package/lib/cjs/tests/tests.js +131 -127
  14. package/lib/cjs/tests/tests.js.map +1 -1
  15. package/lib/esm/sdk.d.ts +2 -2
  16. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts +6 -0
  17. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -0
  18. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +309 -0
  19. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -0
  20. package/lib/esm/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
  21. package/lib/esm/tests/api_tests/medication_added_trigger.test.js +206 -136
  22. package/lib/esm/tests/api_tests/medication_added_trigger.test.js.map +1 -1
  23. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +10 -0
  24. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -1
  25. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +184 -41
  26. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -1
  27. package/lib/esm/tests/tests.d.ts.map +1 -1
  28. package/lib/esm/tests/tests.js +131 -127
  29. package/lib/esm/tests/tests.js.map +1 -1
  30. package/lib/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +10 -10
  32. package/src/tests/api_tests/healthie_multi_integration.test.ts +245 -0
  33. package/src/tests/api_tests/medication_added_trigger.test.ts +59 -1
  34. package/src/tests/api_tests/security/F-0005-ai-conversations-rbac.test.ts +123 -0
  35. package/src/tests/tests.ts +2 -0
  36. package/test_generated.pdf +0 -0
@@ -0,0 +1,245 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../setup"
11
+ import { BUILT_INS_FOR_SET_FIELDS, HEALTHIE_TITLE } from "@tellescope/constants"
12
+
13
+ const host = process.env.API_URL || 'http://localhost:8080' as const
14
+
15
+ const TAG = 'clinic-two'
16
+ const FORMSORT_TEST_KEY = '9d4f9dff00f60df2690a16da2cb848f289b447614ad9bef850e54af09a1fbf7a'
17
+
18
+ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
19
+ log_header("Multiple Healthie Integrations Tests")
20
+
21
+ const businessId = sdk.userInfo.businessId
22
+
23
+ let primaryIntegrationId = ''
24
+ let taggedIntegrationId = ''
25
+ let enduserId = ''
26
+ let formsortEnduserId = ''
27
+ let formId = ''
28
+
29
+ try {
30
+ // ── Set Fields support ────────────────────────────────────────────────────────────────────
31
+ if (!BUILT_INS_FOR_SET_FIELDS.includes('healthieIntegrationId')) {
32
+ throw new Error("healthieIntegrationId missing from BUILT_INS_FOR_SET_FIELDS")
33
+ }
34
+ console.log("✅ healthieIntegrationId exposed in BUILT_INS_FOR_SET_FIELDS")
35
+
36
+ // ── Create primary (untagged, sandbox-style key) + additional (tagged, production-style key)
37
+ // via the standard REST create endpoint — the real creation path for additional connections ──
38
+ const primary = await sdk.api.integrations.createOne({
39
+ title: HEALTHIE_TITLE,
40
+ disableEnduserAutoSync: true, // fake keys — avoid auto-sync attempts during the test
41
+ disableTicketAutoSync: true,
42
+ authentication: {
43
+ type: 'apiKey',
44
+ info: { access_token: 'gh_sbox_fake_primary_key', refresh_token: 'unused', scope: '', token_type: 'Bearer', expiry_date: 0 },
45
+ },
46
+ })
47
+ primaryIntegrationId = primary.id
48
+
49
+ const tagged = await sdk.api.integrations.createOne({
50
+ title: HEALTHIE_TITLE,
51
+ tenantId: TAG,
52
+ disableEnduserAutoSync: true,
53
+ disableTicketAutoSync: true,
54
+ authentication: {
55
+ type: 'apiKey',
56
+ info: { access_token: 'fake_production_key', refresh_token: 'unused', scope: '', token_type: 'Bearer', expiry_date: 0 },
57
+ },
58
+ })
59
+ taggedIntegrationId = tagged.id
60
+
61
+ if (tagged.tenantId !== TAG) {
62
+ throw new Error(`tenantId not persisted on create (got "${tagged.tenantId}")`)
63
+ }
64
+ console.log("✅ tenantId persists via standard integrations create")
65
+
66
+ // ── Per-connection settings: update_settings works against a tagged integration ──
67
+ await async_test(
68
+ "update_settings updates a tagged integration",
69
+ () => sdk.api.integrations.update_settings({ id: taggedIntegrationId, updates: { pushAddedTags: true } }),
70
+ { onResult: r => r.integration?.pushAddedTags === true },
71
+ )
72
+ await async_test(
73
+ "tagged integration settings persist independently",
74
+ () => sdk.api.integrations.getOne(taggedIntegrationId),
75
+ { onResult: i => i.pushAddedTags === true && i.tenantId === TAG },
76
+ )
77
+ await async_test(
78
+ "primary integration unaffected by tagged settings update",
79
+ () => sdk.api.integrations.getOne(primaryIntegrationId),
80
+ { onResult: i => !i.pushAddedTags },
81
+ )
82
+
83
+ // ── Organization.healthieIntegrationIds pushed by create side effect ──
84
+ await wait(undefined, 2000) // side effects are async
85
+ await async_test(
86
+ "organization.healthieIntegrationIds includes tag after create",
87
+ () => sdk.api.organizations.getOne(businessId),
88
+ { onResult: o => !!(o as any).healthieIntegrationIds?.includes(TAG) },
89
+ )
90
+
91
+ // ── Inbound webhook resolution (help page proves the integration lookup without network) ──
92
+ await async_test(
93
+ "webhook help page resolves primary (sandbox) without query param",
94
+ () => axios.get(`${host}/v1/webhooks/healthie/${businessId}`),
95
+ { onResult: r => typeof r.data === 'string' && r.data.startsWith('Sandbox') },
96
+ )
97
+ await async_test(
98
+ "webhook help page resolves tagged (production) integration via healthieIntegrationId param",
99
+ () => axios.get(`${host}/v1/webhooks/healthie/${businessId}?healthieIntegrationId=${TAG}`),
100
+ { onResult: r => typeof r.data === 'string' && r.data.startsWith('Production') },
101
+ )
102
+ await async_test(
103
+ "webhook help page rejects unknown healthieIntegrationId",
104
+ () => axios.get(`${host}/v1/webhooks/healthie/${businessId}?healthieIntegrationId=not-a-real-id`),
105
+ { shouldError: true, onError: (e: any) => e?.response?.status === 400 },
106
+ )
107
+
108
+ // ── Enduser field + one-org-per-patient lock (relationship constraint) ──
109
+ const enduser = await sdk.api.endusers.createOne({
110
+ email: 'multi-healthie-test@tellescope.com',
111
+ healthieIntegrationId: TAG,
112
+ })
113
+ enduserId = enduser.id
114
+ if (enduser.healthieIntegrationId !== TAG) {
115
+ throw new Error("healthieIntegrationId not persisted on enduser create")
116
+ }
117
+
118
+ await async_test(
119
+ "healthieIntegrationId freely changeable while unlinked",
120
+ () => sdk.api.endusers.updateOne(enduserId, { healthieIntegrationId: '' }),
121
+ { onResult: e => !e.healthieIntegrationId },
122
+ )
123
+ await async_test(
124
+ "healthieIntegrationId re-set while unlinked",
125
+ () => sdk.api.endusers.updateOne(enduserId, { healthieIntegrationId: TAG }),
126
+ { onResult: e => e.healthieIntegrationId === TAG },
127
+ )
128
+
129
+ // link the patient to Healthie (reference with a Healthie patient id)
130
+ await sdk.api.endusers.updateOne(enduserId, { references: [{ type: HEALTHIE_TITLE, id: '12345' }] } as any, { replaceObjectFields: true })
131
+
132
+ await async_test(
133
+ "healthieIntegrationId locked once a Healthie patient ID exists",
134
+ () => sdk.api.endusers.updateOne(enduserId, { healthieIntegrationId: 'somewhere-else' }),
135
+ { shouldError: true, onError: (e: any) => (e?.message || e?.toString() || '').includes('healthieIntegrationId') },
136
+ )
137
+ await async_test(
138
+ "no-op write of the same value still allowed while locked",
139
+ () => sdk.api.endusers.updateOne(enduserId, { healthieIntegrationId: TAG }),
140
+ { onResult: e => e.healthieIntegrationId === TAG },
141
+ )
142
+
143
+ // unlink → changeable again
144
+ await sdk.api.endusers.updateOne(enduserId, { references: [] } as any, { replaceObjectFields: true })
145
+ await async_test(
146
+ "healthieIntegrationId changeable again after unlinking",
147
+ () => sdk.api.endusers.updateOne(enduserId, { healthieIntegrationId: '' }),
148
+ { onResult: e => !e.healthieIntegrationId },
149
+ )
150
+
151
+ // ── Formsort mapping ──
152
+ const form = await sdk.api.forms.createOne({ title: "Multi Healthie FormSort" })
153
+ formId = form.id
154
+
155
+ const formsortEmail = 'multi-healthie-formsort@tellescope.com'
156
+ const postToFormsort = (answers: { key: string, value: any }[], responder_uuid: string) => {
157
+ const url = new URL(`${host}/v1/webhooks/formsort/${FORMSORT_TEST_KEY}`)
158
+ url.searchParams.set('formId', form.id)
159
+ return axios.post(url.toString(), { answers, responder_uuid, finalized: true })
160
+ }
161
+
162
+ await postToFormsort([
163
+ { key: 'email', value: formsortEmail },
164
+ { key: 'healthieIntegrationId', value: TAG },
165
+ ], "multi-healthie-1")
166
+
167
+ await async_test(
168
+ "formsort sets healthieIntegrationId on unlinked enduser",
169
+ () => sdk.api.endusers.getSome({ filter: { email: formsortEmail } }),
170
+ { onResult: es => {
171
+ const e = es.find(e => e.email === formsortEmail)
172
+ formsortEnduserId = e?.id || ''
173
+ return e?.healthieIntegrationId === TAG
174
+ }},
175
+ )
176
+
177
+ // link the formsort enduser, then attempt a relink via formsort — field change must be dropped
178
+ await sdk.api.endusers.updateOne(formsortEnduserId, { references: [{ type: HEALTHIE_TITLE, id: '6789' }] } as any, { replaceObjectFields: true })
179
+ await postToFormsort([
180
+ { key: 'email', value: formsortEmail },
181
+ { key: 'healthieIntegrationId', value: 'somewhere-else' },
182
+ ], "multi-healthie-2")
183
+ await wait(undefined, 1000)
184
+
185
+ await async_test(
186
+ "formsort relink attempt on linked enduser is dropped",
187
+ () => sdk.api.endusers.getOne(formsortEnduserId),
188
+ { onResult: e => e.healthieIntegrationId === TAG },
189
+ )
190
+ await async_test(
191
+ "formsort relink attempt logs a customer-visible background error",
192
+ () => sdk.api.background_errors.getSome(),
193
+ { onResult: errors => errors.some(e =>
194
+ e.title === "Healthie Integration Change Blocked" && e.enduserId === formsortEnduserId
195
+ )},
196
+ )
197
+
198
+ // ── Removing the primary must not affect the tagged integration; org list recomputes on delete ──
199
+ await sdk.api.integrations.deleteOne(primaryIntegrationId)
200
+ primaryIntegrationId = ''
201
+ await async_test(
202
+ "tagged integration survives primary deletion",
203
+ () => sdk.api.integrations.getOne(taggedIntegrationId),
204
+ { onResult: i => i.tenantId === TAG },
205
+ )
206
+
207
+ await sdk.api.integrations.deleteOne(taggedIntegrationId)
208
+ taggedIntegrationId = ''
209
+ await wait(undefined, 2000) // delete side effect is async
210
+ await async_test(
211
+ "organization.healthieIntegrationIds recomputed (emptied) after delete",
212
+ () => sdk.api.organizations.getOne(businessId),
213
+ { onResult: o => !(o as any).healthieIntegrationIds?.includes(TAG) },
214
+ )
215
+ } finally {
216
+ for (const id of [primaryIntegrationId, taggedIntegrationId]) {
217
+ if (!id) continue
218
+ await sdk.api.integrations.deleteOne(id).catch(console.error)
219
+ }
220
+ if (enduserId) await sdk.api.endusers.deleteOne(enduserId).catch(console.error)
221
+ if (formsortEnduserId) await sdk.api.endusers.deleteOne(formsortEnduserId).catch(console.error)
222
+ if (formId) await sdk.api.forms.deleteOne(formId).catch(console.error)
223
+ }
224
+ }
225
+
226
+ if (require.main === module) {
227
+ console.log(`Using API URL: ${host}`)
228
+ const sdk = new Session({ host })
229
+ const sdkNonAdmin = new Session({ host })
230
+
231
+ const runTests = async () => {
232
+ await setup_tests(sdk, sdkNonAdmin)
233
+ await healthie_multi_integration_tests({ sdk, sdkNonAdmin })
234
+ }
235
+
236
+ runTests()
237
+ .then(() => {
238
+ console.log("✅ Multiple Healthie integrations test suite completed successfully")
239
+ process.exit(0)
240
+ })
241
+ .catch((error) => {
242
+ console.error("❌ Multiple Healthie integrations test suite failed:", error)
243
+ process.exit(1)
244
+ })
245
+ }
@@ -182,7 +182,7 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
182
182
  title: "Medication - Beluga Protocol Test"
183
183
  })
184
184
 
185
- // Simulate the Beluga RX_WRITTEN webhook with two meds covering category present (typical) and "N/A"
185
+ // Simulate the Beluga RX_WRITTEN webhook with two meds covering category/pharmacyNotes present (typical) and "N/A"/absent
186
186
  const webhookResponse = await fetch(`${host}/v1/webhooks/beluga`, {
187
187
  method: 'POST',
188
188
  headers: { 'Content-Type': 'application/json' },
@@ -198,6 +198,7 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
198
198
  medId: 'test-ndc-123',
199
199
  rxId: 'test-rx-456',
200
200
  category: 'weightloss1',
201
+ pharmacyNotes: 'Dispense with syringes',
201
202
  }, {
202
203
  name: 'Metformin',
203
204
  strength: '500mg',
@@ -245,6 +246,16 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
245
246
  async () => belugaMedMetformin,
246
247
  { onResult: (m: EnduserMedication) => m?.category === 'N/A' }
247
248
  )
249
+ await async_test(
250
+ "Beluga RX_WRITTEN - Medication has notes from webhook pharmacyNotes",
251
+ async () => belugaMedSemaglutide,
252
+ { onResult: (m: EnduserMedication) => m?.notes === 'Dispense with syringes' }
253
+ )
254
+ await async_test(
255
+ "Beluga RX_WRITTEN - Medication omits notes when pharmacyNotes not provided on med",
256
+ async () => belugaMedMetformin,
257
+ { onResult: (m: EnduserMedication) => m?.notes === undefined || m?.notes === null }
258
+ )
248
259
 
249
260
  // Backwards-compatibility: a webhook with no category on any med should result in undefined category
250
261
  const belugaFormNoCategory = await sdk.api.forms.createOne({
@@ -294,6 +305,11 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
294
305
  async () => belugaMedNoCategory,
295
306
  { onResult: (m: EnduserMedication) => m?.category === undefined || m?.category === null }
296
307
  )
308
+ await async_test(
309
+ "Beluga RX_WRITTEN - Medication omits notes when pharmacyNotes not provided",
310
+ async () => belugaMedNoCategory,
311
+ { onResult: (m: EnduserMedication) => m?.notes === undefined || m?.notes === null }
312
+ )
297
313
 
298
314
  try {
299
315
  // Clean up Beluga test data
@@ -389,14 +405,56 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
389
405
  { onResult: (e: Enduser) => e.fields?.['Medication Category'] === '' }
390
406
  )
391
407
 
408
+ // ---- Set Fields with {{medication.notes}} Test ----
409
+ log_header("Medication Added - Set Fields with {{medication.notes}}")
410
+
411
+ const setFieldsPharmacyNotesTrigger = await sdk.api.automation_triggers.createOne({
412
+ event: { type: 'Medication Added', info: { titles: [], protocols: [] } },
413
+ action: { type: 'Set Fields', info: { fields: [{ name: 'Medication Notes', type: 'Custom Value' as const, value: '{{medication.notes}}' }] }},
414
+ status: 'Active',
415
+ title: "Medication - Set Fields medication.notes"
416
+ })
417
+
418
+ const setFieldsPharmacyNotesEnduser = await sdk.api.endusers.createOne({})
419
+
420
+ const setFieldsPharmacyNotesMed = await sdk.api.enduser_medications.createOne({
421
+ enduserId: setFieldsPharmacyNotesEnduser.id,
422
+ title: 'Ozempic 1mg',
423
+ notes: 'Dispense with syringes',
424
+ })
425
+ await wait(undefined, 500)
426
+
427
+ await async_test(
428
+ "Medication Added - Set Fields copies medication.notes to enduser field",
429
+ () => sdk.api.endusers.getOne(setFieldsPharmacyNotesEnduser.id),
430
+ { onResult: (e: Enduser) => e.fields?.['Medication Notes'] === 'Dispense with syringes' }
431
+ )
432
+
433
+ // notes absent on medication → placeholder resolves to empty string
434
+ const setFieldsPharmacyNotesEnduserEmpty = await sdk.api.endusers.createOne({})
435
+ const setFieldsPharmacyNotesMedEmpty = await sdk.api.enduser_medications.createOne({
436
+ enduserId: setFieldsPharmacyNotesEnduserEmpty.id,
437
+ title: 'Lisinopril 40mg',
438
+ })
439
+ await wait(undefined, 500)
440
+
441
+ await async_test(
442
+ "Medication Added - Set Fields with no notes resolves to empty string",
443
+ () => sdk.api.endusers.getOne(setFieldsPharmacyNotesEnduserEmpty.id),
444
+ { onResult: (e: Enduser) => e.fields?.['Medication Notes'] === '' }
445
+ )
446
+
392
447
  try {
393
448
  await sdk.api.automation_triggers.deleteOne(setFieldsTrigger.id)
394
449
  await sdk.api.automation_triggers.deleteOne(setFieldsTriggerFiltered.id)
395
450
  await sdk.api.automation_triggers.deleteOne(setFieldsCategoryTrigger.id)
451
+ await sdk.api.automation_triggers.deleteOne(setFieldsPharmacyNotesTrigger.id)
396
452
  await sdk.api.endusers.deleteOne(setFieldsEnduser.id)
397
453
  await sdk.api.endusers.deleteOne(setFieldsEnduser2.id)
398
454
  await sdk.api.endusers.deleteOne(setFieldsCategoryEnduser.id)
399
455
  await sdk.api.endusers.deleteOne(setFieldsCategoryEnduserEmpty.id)
456
+ await sdk.api.endusers.deleteOne(setFieldsPharmacyNotesEnduser.id)
457
+ await sdk.api.endusers.deleteOne(setFieldsPharmacyNotesEnduserEmpty.id)
400
458
  } finally {}
401
459
 
402
460
  // ---- titleCondition (compound conditional logic on title) ----
@@ -28,6 +28,16 @@ const [nonAdminEmail, nonAdminPassword] = [process.env.NON_ADMIN_EMAIL, process.
28
28
  * 2. Assigns the role to the non-admin user.
29
29
  * 3. Calls each endpoint as the non-admin.
30
30
  * 4. Asserts each endpoint returns a 403-equivalent error (not 200).
31
+ * 5. Positive case: a role granting ai_conversations create (read/update denied) must pass
32
+ * the send_message gate — generating a NEW conversation requires only create access.
33
+ * 6. But the same role must still 403 when passing a conversationId: continuing an existing
34
+ * conversation reads its stored history (returned in the response) and appends to it,
35
+ * so it requires read + update access in addition to create.
36
+ * 7. Default-provider-permissions role (ai_conversations = Assigned): send_message must not
37
+ * 500. Regression: the post-generation $push previously ran through the caller's
38
+ * access-scoped DB, where "Assigned" filters can't match the just-created conversation,
39
+ * returning null and crashing the handler (Cannot read properties of null '_id') after
40
+ * credits were consumed. bedrock.ts now persists via tenant-scoped org-wide queries.
31
41
  *
32
42
  * Pre-fix:
33
43
  * - send_message: 200 (or some downstream error from Bedrock) — NOT 403. Test fails.
@@ -39,7 +49,11 @@ export const ai_conversations_rbac_tests = async ({ sdk, sdkNonAdmin } : { sdk:
39
49
  log_header("F-0005: ai_conversations RBAC bypass regression")
40
50
 
41
51
  const roleName = `f0005-ai-conversations-no-access-${Date.now()}`
52
+ const grantRoleName = `f0005-ai-conversations-create-granted-${Date.now()}`
53
+ const assignedRoleName = `f0005-ai-conversations-assigned-default-${Date.now()}`
42
54
  let rbapId: string | undefined
55
+ let grantRbapId: string | undefined
56
+ let assignedRbapId: string | undefined
43
57
  const originalRoles = sdkNonAdmin.userInfo.roles
44
58
 
45
59
  try {
@@ -109,6 +123,109 @@ export const ai_conversations_rbac_tests = async ({ sdk, sdkNonAdmin } : { sdk:
109
123
  },
110
124
  },
111
125
  )
126
+
127
+ // 4. Positive case: granting only ai_conversations create must pass the RBAC gate.
128
+ // send_message may still fail downstream (e.g. 400 "Organization has not set up credits"),
129
+ // but it must NOT be an access-denial error.
130
+ const grantRbap = await sdk.api.role_based_access_permissions.createOne({
131
+ role: grantRoleName,
132
+ permissions: {
133
+ ...PROVIDER_PERMISSIONS,
134
+ ai_conversations: { create: 'All', read: null, update: null, delete: null },
135
+ },
136
+ })
137
+ grantRbapId = grantRbap.id
138
+
139
+ await sdk.api.users.updateOne(
140
+ sdkNonAdmin.userInfo.id,
141
+ { roles: [grantRoleName] },
142
+ { replaceObjectFields: true },
143
+ )
144
+ await wait(undefined, 1500)
145
+ await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!)
146
+
147
+ await async_test(
148
+ "F-0005: ai_conversations.send_message must NOT be access-denied when role grants create",
149
+ () => sdkNonAdmin.api.ai_conversations.send_message({
150
+ message: 'F-0005 positive-case test',
151
+ type: 'Test',
152
+ maxTokens: 1,
153
+ } as any)
154
+ .then(() => 'allowed' as const)
155
+ .catch((e: any) => {
156
+ const msg = (e?.message ?? '').toLowerCase()
157
+ const status = e?.status ?? e?.code
158
+ if (status === 403 || status === 401
159
+ || msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')) {
160
+ throw e // access denial — the gate incorrectly blocked a create-granted role
161
+ }
162
+ return 'allowed' as const // downstream (e.g. credits) error is fine — the gate passed
163
+ }),
164
+ { onResult: r => r === 'allowed' },
165
+ )
166
+
167
+ // 5. The create-granted (read/update-denied) role must still be blocked from CONTINUING an
168
+ // existing conversation — send_message with conversationId returns the full stored history
169
+ // and appends to it. Fake id is fine: the access check must fire before any lookup.
170
+ await async_test(
171
+ "F-0005: ai_conversations.send_message with conversationId must 403 when role denies read/update",
172
+ () => sdkNonAdmin.api.ai_conversations.send_message({
173
+ message: 'F-0005 conversationId bypass test',
174
+ type: 'Test',
175
+ maxTokens: 1,
176
+ conversationId: new ObjectId().toHexString(),
177
+ } as any),
178
+ {
179
+ shouldError: true,
180
+ onError: (e: any) => {
181
+ const msg = (e?.message ?? '').toLowerCase()
182
+ const status = e?.status ?? e?.code
183
+ return status === 403 || status === 401
184
+ || msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')
185
+ },
186
+ },
187
+ )
188
+
189
+ // 6. Regression: a role with default provider permissions (ai_conversations = Assigned)
190
+ // must be able to generate a new conversation without a 500. Previously the
191
+ // post-generation $push ran through the access-scoped DB, matched nothing under
192
+ // "Assigned", and crashed the handler with "Cannot read properties of null ('_id')".
193
+ const assignedRbap = await sdk.api.role_based_access_permissions.createOne({
194
+ role: assignedRoleName,
195
+ permissions: { ...PROVIDER_PERMISSIONS },
196
+ })
197
+ assignedRbapId = assignedRbap.id
198
+
199
+ await sdk.api.users.updateOne(
200
+ sdkNonAdmin.userInfo.id,
201
+ { roles: [assignedRoleName] },
202
+ { replaceObjectFields: true },
203
+ )
204
+ await wait(undefined, 1500)
205
+ await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!)
206
+
207
+ await async_test(
208
+ "F-0005: ai_conversations.send_message must not 500 for Assigned-access (default provider) role",
209
+ () => sdkNonAdmin.api.ai_conversations.send_message({
210
+ message: 'F-0005 assigned-access regression test',
211
+ type: 'Test',
212
+ maxTokens: 1,
213
+ } as any)
214
+ .then(() => 'ok' as const)
215
+ .catch((e: any) => {
216
+ const msg = (e?.message ?? '').toLowerCase()
217
+ const status = e?.status ?? e?.code
218
+ if (status === 500 || msg.includes('internal error') || msg.includes('cannot read properties')) {
219
+ throw e // the null-update crash — regression
220
+ }
221
+ if (status === 403 || status === 401
222
+ || msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')) {
223
+ throw e // Assigned access must pass the RBAC gate
224
+ }
225
+ return 'ok' as const // downstream (e.g. credits) error is fine
226
+ }),
227
+ { onResult: r => r === 'ok' },
228
+ )
112
229
  } finally {
113
230
  // Cleanup: restore original roles, delete the test role.
114
231
  try {
@@ -121,6 +238,12 @@ export const ai_conversations_rbac_tests = async ({ sdk, sdkNonAdmin } : { sdk:
121
238
  if (rbapId) {
122
239
  try { await sdk.api.role_based_access_permissions.deleteOne(rbapId) } catch {}
123
240
  }
241
+ if (grantRbapId) {
242
+ try { await sdk.api.role_based_access_permissions.deleteOne(grantRbapId) } catch {}
243
+ }
244
+ if (assignedRbapId) {
245
+ try { await sdk.api.role_based_access_permissions.deleteOne(assignedRbapId) } catch {}
246
+ }
124
247
  // Re-authenticate the non-admin to drop the no-access role from their JWT
125
248
  // before subsequent tests run.
126
249
  // Role restore above re-triggers deauthenticate_user; wait > 1s so the freshly minted
@@ -39,6 +39,7 @@ import { enduser_observations_acknowledge_tests } from "./api_tests/enduser_obse
39
39
  import { translations_tests } from "./api_tests/translations.test"
40
40
  import { user_portal_settings_tests } from "./api_tests/user_portal_settings.test"
41
41
  import { integrations_redacted_tests } from "./api_tests/integrations_redacted.test"
42
+ import { healthie_multi_integration_tests } from "./api_tests/healthie_multi_integration.test"
42
43
  import { get_some_projection_tests } from "./api_tests/get_some_projection.test"
43
44
  import { mdb_sort_tests } from "./api_tests/mdb_sort.test"
44
45
  import { create_user_notifications_trigger_tests } from "./api_tests/create_user_notifications_trigger.test"
@@ -15073,6 +15074,7 @@ const ip_address_form_tests = async () => {
15073
15074
  await openloop_webhooks_tests({ sdk, sdkNonAdmin })
15074
15075
  await mdi_webhooks_tests({ sdk, sdkNonAdmin })
15075
15076
  await integrations_redacted_tests({ sdk, sdkNonAdmin })
15077
+ await healthie_multi_integration_tests({ sdk, sdkNonAdmin })
15076
15078
  await mdb_sort_tests({ sdk, sdkNonAdmin })
15077
15079
  await search_tests()
15078
15080
  await time_tracks_tests({ sdk, sdkNonAdmin })
Binary file