@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.
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +316 -0
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/medication_added_trigger.test.js +206 -136
- package/lib/cjs/tests/api_tests/medication_added_trigger.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +10 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +184 -41
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +131 -127
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +309 -0
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -0
- package/lib/esm/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/medication_added_trigger.test.js +206 -136
- package/lib/esm/tests/api_tests/medication_added_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +10 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +184 -41
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +131 -127
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/healthie_multi_integration.test.ts +245 -0
- package/src/tests/api_tests/medication_added_trigger.test.ts +59 -1
- package/src/tests/api_tests/security/F-0005-ai-conversations-rbac.test.ts +123 -0
- package/src/tests/tests.ts +2 -0
- 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
|
package/src/tests/tests.ts
CHANGED
|
@@ -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 })
|
package/test_generated.pdf
CHANGED
|
Binary file
|