@tellescope/sdk 1.255.17 → 1.255.19
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/sdk.d.ts +7 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +215 -112
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +773 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +211 -178
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +1 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +10 -4
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +1 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +215 -112
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +766 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +211 -178
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
- package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
- package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
- package/src/tests/api_tests/scoped_api_keys.test.ts +87 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +506 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
- package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
- package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
- package/src/tests/tests.ts +20 -1
- package/test_generated.pdf +0 -0
- package/.env +0 -13
package/src/sdk.ts
CHANGED
|
@@ -716,6 +716,9 @@ type Queries = { [K in keyof ClientModelForName]: APIQuery<K> } & {
|
|
|
716
716
|
remove_conference_attendees: (args: extractFields<CustomActions['phone_calls']['remove_conference_attendees']['parameters']>) => (
|
|
717
717
|
Promise<extractFields<CustomActions['phone_calls']['remove_conference_attendees']['returns']>>
|
|
718
718
|
),
|
|
719
|
+
modify_conference_attendee_status: (args: extractFields<CustomActions['phone_calls']['modify_conference_attendee_status']['parameters']>) => (
|
|
720
|
+
Promise<extractFields<CustomActions['phone_calls']['modify_conference_attendee_status']['returns']>>
|
|
721
|
+
),
|
|
719
722
|
end_conference: (args: extractFields<CustomActions['phone_calls']['end_conference']['parameters']>) => (
|
|
720
723
|
Promise<extractFields<CustomActions['phone_calls']['end_conference']['returns']>>
|
|
721
724
|
),
|
|
@@ -1163,6 +1166,7 @@ export class Session extends SessionManager {
|
|
|
1163
1166
|
queries.phone_calls.cold_transfer = a => this._POST(`/v1${schema.phone_calls.customActions.cold_transfer.path}`, a)
|
|
1164
1167
|
queries.phone_calls.add_conference_attendees = a => this._POST(`/v1${schema.phone_calls.customActions.add_conference_attendees.path}`, a)
|
|
1165
1168
|
queries.phone_calls.remove_conference_attendees = a => this._POST(`/v1${schema.phone_calls.customActions.remove_conference_attendees.path}`, a)
|
|
1169
|
+
queries.phone_calls.modify_conference_attendee_status = a => this._POST(`/v1${schema.phone_calls.customActions.modify_conference_attendee_status.path}`, a)
|
|
1166
1170
|
queries.phone_calls.end_conference = a => this._POST(`/v1${schema.phone_calls.customActions.end_conference.path}`, a)
|
|
1167
1171
|
queries.phone_calls.cancel_recording = a => this._POST(`/v1${schema.phone_calls.customActions.cancel_recording.path}`, a)
|
|
1168
1172
|
queries.phone_calls.delete_recordings = a => this._POST(`/v1${schema.phone_calls.customActions.delete_recordings.path}`, a)
|
|
@@ -547,9 +547,9 @@ export const auto_merge_form_submission_tests = async ({ sdk, sdkNonAdmin }: { s
|
|
|
547
547
|
{ expectedResult: true }
|
|
548
548
|
)
|
|
549
549
|
|
|
550
|
-
// Test 12: Intake fields update destination enduser directly
|
|
550
|
+
// Test 12: Intake fields update destination enduser directly — except the authentication channels
|
|
551
551
|
await async_test(
|
|
552
|
-
"Auto-merge: Intake fields update destination enduser directly",
|
|
552
|
+
"Auto-merge: Intake fields update destination enduser directly (email/phone locked)",
|
|
553
553
|
async () => {
|
|
554
554
|
const testEmail = `automerge.intake.${Date.now()}@test.com`
|
|
555
555
|
const destination = await sdk.api.endusers.createOne({
|
|
@@ -579,10 +579,19 @@ export const auto_merge_form_submission_tests = async ({ sdk, sdkNonAdmin }: { s
|
|
|
579
579
|
await sdk.api.forms.deleteOne(form.id)
|
|
580
580
|
await sdk.api.endusers.deleteOne(destination.id)
|
|
581
581
|
|
|
582
|
-
//
|
|
582
|
+
// Non-auth intake fields are still applied to the destination — dateOfBirth landing here is
|
|
583
|
+
// also what proves the intake write ran at all, so the negative assertions below aren't vacuous.
|
|
583
584
|
return sourceDeleted
|
|
584
|
-
&& updatedDestination.phone === newPhone
|
|
585
585
|
&& updatedDestination.dateOfBirth === newDOB
|
|
586
|
+
// ...but email and phone are NOT applied. The merge attaches this response to a contact that
|
|
587
|
+
// existed before the submission, which sets FormResponse.authFieldIntakeDisabled, and these two
|
|
588
|
+
// are authentication channels (password reset resolves by email; OTPs are delivered to phone).
|
|
589
|
+
// Filling a BLANK phone is deliberately blocked too — contact-less patients are the primary
|
|
590
|
+
// takeover target, so "fill only if empty" would not close the hole. Staff update these fields.
|
|
591
|
+
// Previously this test asserted `phone === newPhone`; that behaviour was the vulnerability.
|
|
592
|
+
// See api_tests/security/public_endpoint_auth_field_lock.test.ts (G26).
|
|
593
|
+
&& !updatedDestination.phone
|
|
594
|
+
&& updatedDestination.email === testEmail
|
|
586
595
|
},
|
|
587
596
|
{ expectedResult: true }
|
|
588
597
|
)
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
log_header,
|
|
9
9
|
wait,
|
|
10
10
|
} from "@tellescope/testing"
|
|
11
|
-
import { evaluate_conditional_logic_for_enduser_fields } from "@tellescope/utilities"
|
|
11
|
+
import { evaluate_conditional_logic_for_enduser_fields, objects_equivalent } from "@tellescope/utilities"
|
|
12
12
|
import { setup_tests } from "../setup"
|
|
13
|
-
import { BUILT_INS_FOR_SET_FIELDS, HEALTHIE_TITLE } from "@tellescope/constants"
|
|
13
|
+
import { BUILT_INS_FOR_SET_FIELDS, ENDUSER_FIELD_TYPES, EnduserField, HEALTHIE_TITLE } from "@tellescope/constants"
|
|
14
14
|
|
|
15
15
|
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
16
16
|
|
|
@@ -27,6 +27,12 @@ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : {
|
|
|
27
27
|
let enduserId = ''
|
|
28
28
|
let formsortEnduserId = ''
|
|
29
29
|
let formId = ''
|
|
30
|
+
let hiddenFormId = ''
|
|
31
|
+
let multipleChoiceFormId = ''
|
|
32
|
+
let hiddenIntakeEnduserId = ''
|
|
33
|
+
let multipleChoiceIntakeEnduserId = ''
|
|
34
|
+
let numberIntakeEnduserId = ''
|
|
35
|
+
const formResponseIds: string[] = []
|
|
30
36
|
|
|
31
37
|
try {
|
|
32
38
|
// ── Set Fields support ────────────────────────────────────────────────────────────────────
|
|
@@ -236,6 +242,90 @@ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : {
|
|
|
236
242
|
{ onResult: e => e.healthieIntegrationId === 'somewhere-else' },
|
|
237
243
|
)
|
|
238
244
|
|
|
245
|
+
// ── Form intake field: "Update Patient Profile" writes the top-level enduser column ──
|
|
246
|
+
const submitIntakeResponse = async (enduserId: string, formId: string, response: any) => {
|
|
247
|
+
const { accessCode, response: prepared } = await sdk.api.form_responses.prepare_form_response({ enduserId, formId })
|
|
248
|
+
formResponseIds.push(prepared.id)
|
|
249
|
+
await sdk.api.form_responses.submit_form_response({ accessCode, responses: [response] })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const hiddenForm = await sdk.api.forms.createOne({ title: "Multi Healthie Intake (Hidden Value)" })
|
|
253
|
+
hiddenFormId = hiddenForm.id
|
|
254
|
+
const hiddenField = await sdk.api.form_fields.createOne({
|
|
255
|
+
formId: hiddenForm.id,
|
|
256
|
+
title: "Healthie Integration",
|
|
257
|
+
type: 'Hidden Value',
|
|
258
|
+
intakeField: 'healthieIntegrationId',
|
|
259
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const hiddenIntakeEnduser = await sdk.api.endusers.createOne({ email: 'multi-healthie-intake-hidden@tellescope.com' })
|
|
263
|
+
hiddenIntakeEnduserId = hiddenIntakeEnduser.id
|
|
264
|
+
|
|
265
|
+
await submitIntakeResponse(hiddenIntakeEnduserId, hiddenForm.id, {
|
|
266
|
+
fieldId: hiddenField.id, fieldTitle: hiddenField.title, answer: { type: 'Hidden Value', value: TAG },
|
|
267
|
+
})
|
|
268
|
+
await async_test(
|
|
269
|
+
"Hidden Value intake field sets healthieIntegrationId",
|
|
270
|
+
() => sdk.api.endusers.getOne(hiddenIntakeEnduserId),
|
|
271
|
+
{ onResult: e => e.healthieIntegrationId === TAG },
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
// Multiple Choice answers arrive as arrays — the stored value must be the collapsed scalar,
|
|
275
|
+
// otherwise stringValidator100 rejects the update and takes the whole submission's intake down
|
|
276
|
+
const multipleChoiceForm = await sdk.api.forms.createOne({ title: "Multi Healthie Intake (Multiple Choice)" })
|
|
277
|
+
multipleChoiceFormId = multipleChoiceForm.id
|
|
278
|
+
const multipleChoiceField = await sdk.api.form_fields.createOne({
|
|
279
|
+
formId: multipleChoiceForm.id,
|
|
280
|
+
title: "Which clinic?",
|
|
281
|
+
type: 'multiple_choice',
|
|
282
|
+
intakeField: 'healthieIntegrationId',
|
|
283
|
+
options: { choices: [TAG, 'other-clinic'], radio: true },
|
|
284
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const multipleChoiceIntakeEnduser = await sdk.api.endusers.createOne({ email: 'multi-healthie-intake-mc@tellescope.com' })
|
|
288
|
+
multipleChoiceIntakeEnduserId = multipleChoiceIntakeEnduser.id
|
|
289
|
+
|
|
290
|
+
await submitIntakeResponse(multipleChoiceIntakeEnduserId, multipleChoiceForm.id, {
|
|
291
|
+
fieldId: multipleChoiceField.id, fieldTitle: multipleChoiceField.title, answer: { type: 'multiple_choice', value: [TAG] },
|
|
292
|
+
})
|
|
293
|
+
await async_test(
|
|
294
|
+
"Multiple Choice intake field stores healthieIntegrationId as a scalar",
|
|
295
|
+
() => sdk.api.endusers.getOne(multipleChoiceIntakeEnduserId),
|
|
296
|
+
{ onResult: e => e.healthieIntegrationId === TAG },
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
// Allowed form field types. The form_fields model validates intakeField as a free string (isFormField,
|
|
300
|
+
// which checks ENDUSER_FIELD_TYPES, is not wired into its schema), so this is enforced in the form editor
|
|
301
|
+
// rather than by the API — same as every other intake field. Assert the shared constant the editor reads.
|
|
302
|
+
assert(
|
|
303
|
+
objects_equivalent(
|
|
304
|
+
ENDUSER_FIELD_TYPES['healthieIntegrationId' as EnduserField],
|
|
305
|
+
['Hidden Value', 'Dropdown', 'multiple_choice', 'string'],
|
|
306
|
+
),
|
|
307
|
+
'ENDUSER_FIELD_TYPES error', 'healthieIntegrationId is limited to Hidden Value, Dropdown, Multiple Choice, and Text',
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
// An incompatible type is accepted by the API but must never write a non-string to the enduser column
|
|
311
|
+
const numberField = await sdk.api.form_fields.createOne({
|
|
312
|
+
formId: multipleChoiceForm.id,
|
|
313
|
+
title: "Bad Type",
|
|
314
|
+
type: 'number',
|
|
315
|
+
intakeField: 'healthieIntegrationId',
|
|
316
|
+
previousFields: [{ type: 'after', info: { fieldId: multipleChoiceField.id } }],
|
|
317
|
+
})
|
|
318
|
+
const numberIntakeEnduser = await sdk.api.endusers.createOne({ email: 'multi-healthie-intake-number@tellescope.com' })
|
|
319
|
+
numberIntakeEnduserId = numberIntakeEnduser.id
|
|
320
|
+
await submitIntakeResponse(numberIntakeEnduserId, multipleChoiceForm.id, {
|
|
321
|
+
fieldId: numberField.id, fieldTitle: numberField.title, answer: { type: 'number', value: 5 },
|
|
322
|
+
})
|
|
323
|
+
await async_test(
|
|
324
|
+
"non-string answer for the healthieIntegrationId intake field is ignored",
|
|
325
|
+
() => sdk.api.endusers.getOne(numberIntakeEnduserId),
|
|
326
|
+
{ onResult: e => !e.healthieIntegrationId },
|
|
327
|
+
)
|
|
328
|
+
|
|
239
329
|
// ── Removing the primary must not affect the tagged integration; org list recomputes on delete ──
|
|
240
330
|
await sdk.api.integrations.deleteOne(primaryIntegrationId)
|
|
241
331
|
primaryIntegrationId = ''
|
|
@@ -258,9 +348,17 @@ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : {
|
|
|
258
348
|
if (!id) continue
|
|
259
349
|
await sdk.api.integrations.deleteOne(id).catch(console.error)
|
|
260
350
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
351
|
+
for (const id of formResponseIds) {
|
|
352
|
+
await sdk.api.form_responses.deleteOne(id).catch(console.error)
|
|
353
|
+
}
|
|
354
|
+
for (const id of [enduserId, formsortEnduserId, hiddenIntakeEnduserId, multipleChoiceIntakeEnduserId, numberIntakeEnduserId]) {
|
|
355
|
+
if (!id) continue
|
|
356
|
+
await sdk.api.endusers.deleteOne(id).catch(console.error)
|
|
357
|
+
}
|
|
358
|
+
for (const id of [formId, hiddenFormId, multipleChoiceFormId]) {
|
|
359
|
+
if (!id) continue
|
|
360
|
+
await sdk.api.forms.deleteOne(id).catch(console.error)
|
|
361
|
+
}
|
|
264
362
|
}
|
|
265
363
|
}
|
|
266
364
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
handleAnyError,
|
|
7
|
+
log_header,
|
|
8
|
+
} from "@tellescope/testing"
|
|
9
|
+
import { setup_tests } from "../setup"
|
|
10
|
+
|
|
11
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
|
+
|
|
13
|
+
// Validation-path tests for the modify-conference-attendee-status endpoint. All assertions
|
|
14
|
+
// below hit validations that run before any Twilio access, so no Twilio config is needed.
|
|
15
|
+
// conferenceId is not settable via the public API (only set internally by conference
|
|
16
|
+
// webhooks), so the Twilio hold/unhold branches are covered by manual E2E testing only.
|
|
17
|
+
export const phone_calls_conference_hold_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
18
|
+
log_header("Phone Calls Conference Hold Tests")
|
|
19
|
+
|
|
20
|
+
await async_test(
|
|
21
|
+
"modify_conference_attendee_status: no status fields rejected",
|
|
22
|
+
() => sdk.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAconferenceholdtest0000000000001' }),
|
|
23
|
+
{ shouldError: true, onError: (e: any) => e.message === "At least one status field (e.g. hold) must be provided" }
|
|
24
|
+
)
|
|
25
|
+
await async_test(
|
|
26
|
+
"modify_conference_attendee_status: bogus conferenceId rejected",
|
|
27
|
+
() => sdk.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAdoesnotexist0000000000000000000', hold: true }),
|
|
28
|
+
{ shouldError: true, onError: (e: any) => e.message === "Call not found" }
|
|
29
|
+
)
|
|
30
|
+
await async_test(
|
|
31
|
+
"modify_conference_attendee_status: hold false also validated against record access",
|
|
32
|
+
() => sdk.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAdoesnotexist0000000000000000000', hold: false }),
|
|
33
|
+
{ shouldError: true, onError: (e: any) => e.message === "Call not found" }
|
|
34
|
+
)
|
|
35
|
+
await async_test(
|
|
36
|
+
"modify_conference_attendee_status: non-admin cannot hold an inaccessible conference",
|
|
37
|
+
() => sdkNonAdmin.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAdoesnotexist0000000000000000000', hold: true }),
|
|
38
|
+
handleAnyError
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Allow running this test file independently
|
|
43
|
+
if (require.main === module) {
|
|
44
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
45
|
+
const sdk = new Session({ host })
|
|
46
|
+
const sdkNonAdmin = new Session({ host })
|
|
47
|
+
|
|
48
|
+
const runTests = async () => {
|
|
49
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
50
|
+
await phone_calls_conference_hold_tests({ sdk, sdkNonAdmin })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
runTests()
|
|
54
|
+
.then(() => {
|
|
55
|
+
console.log("✅ Phone calls conference hold test suite completed successfully")
|
|
56
|
+
process.exit(0)
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
console.error("❌ Phone calls conference hold test suite failed:", error)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -35,6 +35,7 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
35
35
|
|
|
36
36
|
const keyIds: string[] = []
|
|
37
37
|
const formResponseIds: string[] = []
|
|
38
|
+
const fileIds: string[] = []
|
|
38
39
|
let throwawayUserId: string | undefined
|
|
39
40
|
let throwawayRoleId: string | undefined
|
|
40
41
|
|
|
@@ -77,6 +78,11 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
77
78
|
}),
|
|
78
79
|
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
79
80
|
)
|
|
81
|
+
await async_test(
|
|
82
|
+
"scoped key cannot create API keys (escalation guard)",
|
|
83
|
+
() => axios.post(`${host}/v1/api-key`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } }),
|
|
84
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
85
|
+
)
|
|
80
86
|
|
|
81
87
|
// =============================================
|
|
82
88
|
// (c) Scoped key IS accepted by the form-ingestion webhook
|
|
@@ -101,6 +107,44 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
101
107
|
)
|
|
102
108
|
if (pathResult.data?.formResponseId) formResponseIds.push(pathResult.data.formResponseId)
|
|
103
109
|
|
|
110
|
+
// =============================================
|
|
111
|
+
// (c2) Scoped key CAN run the file-upload flow (prepare + confirm) — needed for intake forms with files
|
|
112
|
+
// =============================================
|
|
113
|
+
const scopedSession = new Session({ host, apiKey: scoped.key })
|
|
114
|
+
const { file: uploadedFile } = await scopedSession.api.files.prepare_file_upload({
|
|
115
|
+
name: 'scoped-intake-file.txt', size: 12, type: 'text/plain',
|
|
116
|
+
})
|
|
117
|
+
assert(
|
|
118
|
+
!!uploadedFile?.id,
|
|
119
|
+
"scoped key prepare_file_upload returned no file",
|
|
120
|
+
"scoped key can call POST /prepare-file-upload",
|
|
121
|
+
)
|
|
122
|
+
if (uploadedFile?.id) fileIds.push(uploadedFile.id)
|
|
123
|
+
|
|
124
|
+
await async_test(
|
|
125
|
+
"scoped key can call POST /files/confirm-upload",
|
|
126
|
+
() => scopedSession.api.files.confirm_file_upload({ id: uploadedFile.id }),
|
|
127
|
+
{ onResult: () => true },
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// ...but is blocked from EVERY other endpoint in the files domain — proving the two allowed paths are the
|
|
131
|
+
// ONLY files access, and that the '/files/confirm-upload' allow-prefix does not open the '/files/*' namespace.
|
|
132
|
+
const blockedFileEndpoints: [string, () => Promise<any>][] = [
|
|
133
|
+
['GET /v1/files (readMany)', () => axios.get(`${host}/v1/files`, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
|
|
134
|
+
['GET /v1/file/:id (read one)', () => axios.get(`${host}/v1/file/000000000000000000000000`, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
|
|
135
|
+
['DELETE /v1/file/:id (destructive)', () => axios.delete(`${host}/v1/file/000000000000000000000000`, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
|
|
136
|
+
['POST /v1/files/send-fax (/files/* sibling)', () => axios.post(`${host}/v1/files/send-fax`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
|
|
137
|
+
['POST /v1/files/ocr (/files/* sibling)', () => axios.post(`${host}/v1/files/ocr`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
|
|
138
|
+
['POST /v1/files/push (/files/* sibling)', () => axios.post(`${host}/v1/files/push`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
|
|
139
|
+
]
|
|
140
|
+
for (const [label, call] of blockedFileEndpoints) {
|
|
141
|
+
await async_test(
|
|
142
|
+
`scoped key blocked on ${label}`,
|
|
143
|
+
call,
|
|
144
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 || e.response?.status === 403 },
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
104
148
|
// =============================================
|
|
105
149
|
// (d) Non-admin cannot create a scoped key (throwaway user, never mutate existing roles)
|
|
106
150
|
// =============================================
|
|
@@ -185,6 +229,46 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
185
229
|
"get_organization_api_keys still never returns key/hashedKey",
|
|
186
230
|
)
|
|
187
231
|
|
|
232
|
+
// =============================================
|
|
233
|
+
// (g) Legacy non-/v1 funnel (app.js requireLoginAndAccess → is_logged_in_API_key with NO requestPath).
|
|
234
|
+
// Proves scoped keys fail closed on every non-/v1 caller (also stands in for the Medplum webhook path),
|
|
235
|
+
// and that unscoped keys still authenticate the legacy funnel after this work.
|
|
236
|
+
// NOTE: the legacy middleware reads the key from ?apiKey= / body only (never the Authorization header).
|
|
237
|
+
// =============================================
|
|
238
|
+
await async_test(
|
|
239
|
+
"unscoped key authenticates a legacy non-/v1 route",
|
|
240
|
+
() => axios.get(`${host}/API-keys-for-user?apiKey=${encodeURIComponent(unscopedKeyValue)}`),
|
|
241
|
+
{ onResult: (r: any) => r.status === 200 },
|
|
242
|
+
)
|
|
243
|
+
await async_test(
|
|
244
|
+
"scoped key rejected on a legacy non-/v1 route (fail closed, no requestPath)",
|
|
245
|
+
() => axios.get(`${host}/API-keys-for-user?apiKey=${encodeURIComponent(scoped.key)}`),
|
|
246
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
// =============================================
|
|
250
|
+
// (h) Data-egress adjacency: the scope's allowed paths must NOT leak to the file DOWNLOAD endpoint.
|
|
251
|
+
// =============================================
|
|
252
|
+
await async_test(
|
|
253
|
+
"scoped key cannot call file_download_URL (no PHI egress)",
|
|
254
|
+
() => axios.get(`${host}/v1/file-download-URL?secureName=nonexistent`, { headers: { Authorization: `API_KEY ${scoped.key}` } }),
|
|
255
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 || e.response?.status === 403 },
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
// =============================================
|
|
259
|
+
// (i) Transport independence: the scope gate applies whether the key is in the header or the ?apiKey= query.
|
|
260
|
+
// =============================================
|
|
261
|
+
await async_test(
|
|
262
|
+
"scoped key via ?apiKey= query param still rejected on a disallowed /v1 endpoint",
|
|
263
|
+
() => axios.get(`${host}/v1/endusers?apiKey=${encodeURIComponent(scoped.key)}`),
|
|
264
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 || e.response?.status === 403 },
|
|
265
|
+
)
|
|
266
|
+
await async_test(
|
|
267
|
+
"unscoped key via ?apiKey= query param still authenticates /v1 (regression)",
|
|
268
|
+
() => axios.get(`${host}/v1/test-authenticated?apiKey=${encodeURIComponent(unscopedKeyValue)}`),
|
|
269
|
+
{ onResult: (r: any) => r.status === 200 },
|
|
270
|
+
)
|
|
271
|
+
|
|
188
272
|
// =============================================
|
|
189
273
|
// Validation: unknown scope values are rejected
|
|
190
274
|
// =============================================
|
|
@@ -200,6 +284,9 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
200
284
|
for (const id of formResponseIds) {
|
|
201
285
|
await sdk.api.form_responses.deleteOne(id).catch(() => {})
|
|
202
286
|
}
|
|
287
|
+
for (const id of fileIds) {
|
|
288
|
+
await sdk.api.files.deleteOne(id).catch(() => {})
|
|
289
|
+
}
|
|
203
290
|
try {
|
|
204
291
|
const enduser = await sdk.api.endusers.getOne({ email: TEST_EMAIL })
|
|
205
292
|
await sdk.api.endusers.deleteOne(enduser.id)
|
|
@@ -43,8 +43,15 @@ const restrictedEnduserUpdates = (staffId: string, mongoId: string): Record<stri
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
// Every form_responses field marked enduserUpdatesDisabled in schema.ts.
|
|
46
|
-
|
|
46
|
+
// otherEnduserId must be a REAL enduser other than the one submitting the update: enduserId's
|
|
47
|
+
// foreign key is existence-checked, so a nonexistent id would be rejected as a bad dependency
|
|
48
|
+
// rather than by the write restriction under test.
|
|
49
|
+
const restrictedFormResponseUpdates = (staffId: string, otherEnduserId: string): Record<string, any> => ({
|
|
47
50
|
markedAsSubmitted: true,
|
|
51
|
+
// repointing a response at another patient redirects the intake write in
|
|
52
|
+
// save_enduser_updates_for_form_response, which is how email/phone (authentication channels)
|
|
53
|
+
// reach an enduser record — see api_tests/security/public_endpoint_auth_field_lock.test.ts
|
|
54
|
+
enduserId: otherEnduserId,
|
|
48
55
|
submittedBy: staffId,
|
|
49
56
|
submittedAt: new Date("2024-01-01T00:00:00Z"),
|
|
50
57
|
submittedByIsPlaceholder: true,
|
|
@@ -84,6 +91,7 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
|
|
|
84
91
|
log_header("F-0106/F-0110: enduser write restrictions (enduserUpdatesDisabled)")
|
|
85
92
|
|
|
86
93
|
let enduserId: string | undefined
|
|
94
|
+
let otherEnduserId: string | undefined
|
|
87
95
|
let formId: string | undefined
|
|
88
96
|
let formResponseId: string | undefined
|
|
89
97
|
|
|
@@ -92,6 +100,12 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
|
|
|
92
100
|
const enduserEmail = `f0106-enduser-${Date.now()}@example.com`
|
|
93
101
|
const enduser = await sdk.api.endusers.createOne({ email: enduserEmail, fname: 'F0106', lname: 'Restricted' })
|
|
94
102
|
enduserId = enduser.id
|
|
103
|
+
|
|
104
|
+
// The would-be victim of an enduserId repoint, and the destination for the staff positive control.
|
|
105
|
+
const otherEnduser = await sdk.api.endusers.createOne({
|
|
106
|
+
email: `f0106-other-${Date.now()}@example.com`, fname: 'F0106', lname: 'Other',
|
|
107
|
+
})
|
|
108
|
+
otherEnduserId = otherEnduser.id
|
|
95
109
|
await sdk.api.endusers.set_password({ id: enduser.id, password: ENDUSER_PASSWORD })
|
|
96
110
|
|
|
97
111
|
const enduserSDK = new EnduserSession({ host, businessId })
|
|
@@ -123,7 +137,7 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
|
|
|
123
137
|
)
|
|
124
138
|
|
|
125
139
|
// ---- F-0110: enduser self-update of restricted form_responses fields must 400 ----
|
|
126
|
-
for (const [field, value] of Object.entries(restrictedFormResponseUpdates(sdk.userInfo.id))) {
|
|
140
|
+
for (const [field, value] of Object.entries(restrictedFormResponseUpdates(sdk.userInfo.id, otherEnduser.id))) {
|
|
127
141
|
await async_test(
|
|
128
142
|
`F-0110: enduser cannot self-update form_responses.${field}`,
|
|
129
143
|
() => enduserSDK.api.form_responses.updateOne(formResponse.id, { [field]: value } as any),
|
|
@@ -132,9 +146,13 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
|
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
await async_test(
|
|
135
|
-
'F-0110: rejected writes did not persist (markedAsSubmitted, procedureCodes, submittedBy)',
|
|
149
|
+
'F-0110: rejected writes did not persist (markedAsSubmitted, procedureCodes, submittedBy, enduserId)',
|
|
136
150
|
() => sdk.api.form_responses.getOne(formResponse.id),
|
|
137
|
-
{ onResult: fr =>
|
|
151
|
+
{ onResult: fr => (
|
|
152
|
+
!fr.markedAsSubmitted && !fr.procedureCodes?.length && fr.submittedBy === undefined
|
|
153
|
+
// still points at its own patient: a successful repoint is what redirects the intake write
|
|
154
|
+
&& fr.enduserId === enduser.id
|
|
155
|
+
) },
|
|
138
156
|
)
|
|
139
157
|
|
|
140
158
|
// ---- Positive controls: intended enduser self-updates still work ----
|
|
@@ -178,6 +196,19 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
|
|
|
178
196
|
&& !!fr.tags?.includes('staff-set-tag')
|
|
179
197
|
) },
|
|
180
198
|
)
|
|
199
|
+
|
|
200
|
+
// enduserId is enduserUpdatesDisabled rather than updatesDisabled specifically so this keeps
|
|
201
|
+
// working: staff sometimes need to move a response to the correct profile to fix a mistake.
|
|
202
|
+
await async_test(
|
|
203
|
+
'staff can still repoint form_responses.enduserId to another patient',
|
|
204
|
+
() => sdk.api.form_responses.updateOne(formResponse.id, { enduserId: otherEnduser.id }),
|
|
205
|
+
{ onResult: fr => fr.enduserId === otherEnduser.id },
|
|
206
|
+
)
|
|
207
|
+
await async_test(
|
|
208
|
+
'staff can repoint form_responses.enduserId back',
|
|
209
|
+
() => sdk.api.form_responses.updateOne(formResponse.id, { enduserId: enduser.id }),
|
|
210
|
+
{ onResult: fr => fr.enduserId === enduser.id },
|
|
211
|
+
)
|
|
181
212
|
} finally {
|
|
182
213
|
if (formResponseId) {
|
|
183
214
|
try { await sdk.api.form_responses.deleteOne(formResponseId) } catch {}
|
|
@@ -188,6 +219,9 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
|
|
|
188
219
|
if (enduserId) {
|
|
189
220
|
try { await sdk.api.endusers.deleteOne(enduserId) } catch {}
|
|
190
221
|
}
|
|
222
|
+
if (otherEnduserId) {
|
|
223
|
+
try { await sdk.api.endusers.deleteOne(otherEnduserId) } catch {}
|
|
224
|
+
}
|
|
191
225
|
}
|
|
192
226
|
}
|
|
193
227
|
|