@tellescope/sdk 1.256.7 → 1.256.9

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 (51) hide show
  1. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -1
  2. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js +12 -9
  3. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js.map +1 -1
  4. package/lib/cjs/tests/api_tests/create_note_action.test.d.ts +6 -0
  5. package/lib/cjs/tests/api_tests/create_note_action.test.d.ts.map +1 -0
  6. package/lib/cjs/tests/api_tests/create_note_action.test.js +237 -0
  7. package/lib/cjs/tests/api_tests/create_note_action.test.js.map +1 -0
  8. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.d.ts +6 -0
  9. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.d.ts.map +1 -0
  10. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.js +293 -0
  11. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.js.map +1 -0
  12. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -1
  13. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +132 -47
  14. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -1
  15. package/lib/cjs/tests/api_tests/webhook_raw_json_number.test.d.ts +6 -0
  16. package/lib/cjs/tests/api_tests/webhook_raw_json_number.test.d.ts.map +1 -0
  17. package/lib/cjs/tests/api_tests/webhook_raw_json_number.test.js +262 -0
  18. package/lib/cjs/tests/api_tests/webhook_raw_json_number.test.js.map +1 -0
  19. package/lib/cjs/tests/tests.d.ts.map +1 -1
  20. package/lib/cjs/tests/tests.js +262 -223
  21. package/lib/cjs/tests/tests.js.map +1 -1
  22. package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -1
  23. package/lib/esm/tests/api_tests/appointment_link_expiration.test.js +12 -9
  24. package/lib/esm/tests/api_tests/appointment_link_expiration.test.js.map +1 -1
  25. package/lib/esm/tests/api_tests/create_note_action.test.d.ts +6 -0
  26. package/lib/esm/tests/api_tests/create_note_action.test.d.ts.map +1 -0
  27. package/lib/esm/tests/api_tests/create_note_action.test.js +233 -0
  28. package/lib/esm/tests/api_tests/create_note_action.test.js.map +1 -0
  29. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.d.ts +6 -0
  30. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.d.ts.map +1 -0
  31. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.js +289 -0
  32. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.js.map +1 -0
  33. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -1
  34. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +132 -47
  35. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -1
  36. package/lib/esm/tests/api_tests/webhook_raw_json_number.test.d.ts +6 -0
  37. package/lib/esm/tests/api_tests/webhook_raw_json_number.test.d.ts.map +1 -0
  38. package/lib/esm/tests/api_tests/webhook_raw_json_number.test.js +258 -0
  39. package/lib/esm/tests/api_tests/webhook_raw_json_number.test.js.map +1 -0
  40. package/lib/esm/tests/tests.d.ts.map +1 -1
  41. package/lib/esm/tests/tests.js +263 -224
  42. package/lib/esm/tests/tests.js.map +1 -1
  43. package/lib/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +10 -10
  45. package/src/tests/api_tests/appointment_link_expiration.test.ts +7 -2
  46. package/src/tests/api_tests/create_note_action.test.ts +168 -0
  47. package/src/tests/api_tests/group_event_attendee_self_update.test.ts +161 -0
  48. package/src/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.ts +66 -6
  49. package/src/tests/api_tests/webhook_raw_json_number.test.ts +152 -0
  50. package/src/tests/tests.ts +54 -1
  51. package/test_generated.pdf +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellescope/sdk",
3
- "version": "1.256.7",
3
+ "version": "1.256.9",
4
4
  "description": "Code for interacting with the Tellescope API",
5
5
  "main": "./lib/cjs/sdk.js",
6
6
  "module": "./lib/esm/sdk.js",
@@ -32,14 +32,14 @@
32
32
  },
33
33
  "homepage": "https://github.com/tellescope-os/tellescope#readme",
34
34
  "dependencies": {
35
- "@tellescope/constants": "1.256.7",
36
- "@tellescope/schema": "1.256.7",
37
- "@tellescope/testing": "1.256.7",
38
- "@tellescope/types-client": "1.256.7",
39
- "@tellescope/types-models": "1.256.7",
40
- "@tellescope/types-utilities": "1.256.7",
41
- "@tellescope/utilities": "1.256.7",
42
- "@tellescope/validation": "1.256.7",
35
+ "@tellescope/constants": "1.256.9",
36
+ "@tellescope/schema": "1.256.9",
37
+ "@tellescope/testing": "1.256.9",
38
+ "@tellescope/types-client": "1.256.9",
39
+ "@tellescope/types-models": "1.256.9",
40
+ "@tellescope/types-utilities": "1.256.9",
41
+ "@tellescope/utilities": "1.256.9",
42
+ "@tellescope/validation": "1.256.9",
43
43
  "axios": "0.30.3",
44
44
  "dotenv": "14.3.2",
45
45
  "form-data": "4.0.4",
@@ -55,5 +55,5 @@
55
55
  "publishConfig": {
56
56
  "access": "public"
57
57
  },
58
- "gitHead": "65726a9aff1e45ffbdd4a16d8b0fc3a21204fc6f"
58
+ "gitHead": "8952d9b32afa842c1488e4eab27fe1dadc859cdb"
59
59
  }
@@ -24,12 +24,17 @@ const decode_jwt_payload = (token: string) => {
24
24
  // The API returns a /r/<token> shortlink, not the destination — follow one hop and pull the
25
25
  // `token` query param out of the URL the patient actually lands on.
26
26
  const resolve_redirect_token = async (link: string) => {
27
- const { status, headers } = await axios.get(link, {
27
+ // The server stamps the shortlink's host from its own config (organization.customPortalURL || API_URL),
28
+ // which is independent of the API port under test. Follow the /r/<token> path on our test `host` so this
29
+ // stays port-flexible like the rest of the suite (the redirect route is served by the same API server).
30
+ const { pathname, search } = new URL(link)
31
+ const followURL = `${host}${pathname}${search}`
32
+ const { status, headers } = await axios.get(followURL, {
28
33
  maxRedirects: 0,
29
34
  validateStatus: () => true,
30
35
  })
31
36
  const location = headers['location']
32
- if (!location) throw new Error(`Expected a redirect from ${link}, got status ${status}`)
37
+ if (!location) throw new Error(`Expected a redirect from ${followURL}, got status ${status}`)
33
38
 
34
39
  const token = new URL(location).searchParams.get('token')
35
40
  if (!token) throw new Error(`No token query param in redirect destination: ${location}`)
@@ -0,0 +1,168 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import { CreateNoteAutomationAction } from "@tellescope/types-models"
5
+ import { log_header, async_test, assert } from "@tellescope/testing"
6
+ import { setup_tests } from "../setup"
7
+
8
+ const host = process.env.API_URL || "http://localhost:8080"
9
+
10
+ // createNote journey action: creates a note on the enduser timeline with deterministic
11
+ // (merge-field) text, or AI-generated text when aiSummaryConfiguration.enabled is set.
12
+ // The deterministic path is fully covered here; the AI path asserts config round-trip and,
13
+ // when the org lacks Bedrock enablement/credits (typical dev org), the error path instead.
14
+ export const create_note_action_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
15
+ log_header("Create Note Action Tests")
16
+
17
+ const journey = await sdk.api.journeys.createOne({ title: 'Create Note Test Journey' })
18
+ const createdEnduserIds: string[] = []
19
+
20
+ try {
21
+ /*************** Config validation round-trip ***************/
22
+ const validInfo = {
23
+ title: 'Note for {{enduser.fname}}',
24
+ text: 'Hi {{enduser.fname}}, this is a journey note.',
25
+ tags: ['auto-note'],
26
+ }
27
+
28
+ const step = await sdk.api.automation_steps.createOne({
29
+ journeyId: journey.id,
30
+ events: [{ type: 'onJourneyStart', info: {} }],
31
+ action: { type: 'createNote', info: validInfo },
32
+ })
33
+ assert(
34
+ step.action.type === 'createNote' && step.action.info.title === validInfo.title && step.action.info.text === validInfo.text,
35
+ 'createNote step round-trip failed',
36
+ 'createNote step round-trips',
37
+ )
38
+
39
+ await async_test('createNote without title is rejected',
40
+ () => sdk.api.automation_steps.createOne({
41
+ journeyId: journey.id,
42
+ events: [{ type: 'onJourneyStart', info: {} }],
43
+ action: { type: 'createNote', info: { ...validInfo, title: undefined } as any },
44
+ }),
45
+ { shouldError: true, onError: () => true },
46
+ )
47
+ await async_test('createNote with over-length title is rejected',
48
+ () => sdk.api.automation_steps.createOne({
49
+ journeyId: journey.id,
50
+ events: [{ type: 'onJourneyStart', info: {} }],
51
+ action: { type: 'createNote', info: { ...validInfo, title: 'x'.repeat(251) } },
52
+ }),
53
+ { shouldError: true, onError: () => true },
54
+ )
55
+
56
+ /*************** Runtime: the process endpoint ***************/
57
+ const processFor = async (enduserId: string, info: CreateNoteAutomationAction['info']) => {
58
+ const automatedAction = await sdk.api.automated_actions.createOne({
59
+ enduserId,
60
+ journeyId: journey.id,
61
+ automationStepId: step.id,
62
+ event: { type: 'onJourneyStart', info: {} },
63
+ action: { type: 'createNote', info },
64
+ status: 'active',
65
+ processAfter: Date.now(),
66
+ })
67
+ return sdk.api.automated_actions.process({ automatedActionId: automatedAction.id })
68
+ }
69
+
70
+ // deterministic path: merge fields resolve, tags + source set
71
+ const enduser = await sdk.api.endusers.createOne({ fname: 'NoteTest' })
72
+ createdEnduserIds.push(enduser.id)
73
+
74
+ const result = await processFor(enduser.id, validInfo)
75
+ assert(result.success === true, `deterministic createNote failed: ${result.error}`, 'deterministic createNote succeeds')
76
+
77
+ const notes = await sdk.api.notes.getSome({ filter: { enduserId: enduser.id } })
78
+ assert(notes.length === 1, `expected 1 note, got ${notes.length}`, 'exactly one note created')
79
+ assert(notes[0].title === 'Note for NoteTest', `title not templated: ${notes[0].title}`, 'title merge fields resolve')
80
+ assert(notes[0].text === 'Hi NoteTest, this is a journey note.', `text not templated: ${notes[0].text}`, 'text merge fields resolve')
81
+ assert(!!notes[0].tags?.includes('auto-note'), 'tags missing on note', 'tags set on note')
82
+ assert(notes[0].source === 'Journey', `wrong source: ${notes[0].source}`, 'note source is Journey')
83
+ assert(!notes[0].hiddenFromTimeline, 'note unexpectedly hidden', 'note visible on timeline by default')
84
+ assert(!notes[0].aiGenerated, 'deterministic note incorrectly flagged aiGenerated', 'deterministic note not flagged as AI-generated')
85
+
86
+ // hiddenFromTimeline + no text variant
87
+ const hiddenResult = await processFor(enduser.id, { title: 'Hidden note', hiddenFromTimeline: true })
88
+ assert(hiddenResult.success === true, `hidden createNote failed: ${hiddenResult.error}`, 'hidden createNote succeeds')
89
+ const allNotes = await sdk.api.notes.getSome({ filter: { enduserId: enduser.id } })
90
+ const hiddenNote = allNotes.find(n => n.title === 'Hidden note')
91
+ assert(
92
+ !!hiddenNote && hiddenNote.hiddenFromTimeline === true && !hiddenNote.text,
93
+ 'hidden/textless note not created correctly',
94
+ 'hiddenFromTimeline note created without text',
95
+ )
96
+
97
+ /*************** AI path ***************/
98
+ const aiInfo = {
99
+ title: 'AI note for {{enduser.fname}}',
100
+ aiSummaryConfiguration: {
101
+ enabled: true,
102
+ prompt: 'Write a one-sentence note stating this is a test patient.',
103
+ },
104
+ }
105
+ const aiStep = await sdk.api.automation_steps.createOne({
106
+ journeyId: journey.id,
107
+ events: [{ type: 'onJourneyStart', info: {} }],
108
+ action: { type: 'createNote', info: aiInfo },
109
+ })
110
+ assert(
111
+ aiStep.action.type === 'createNote' && aiStep.action.info.aiSummaryConfiguration?.enabled === true,
112
+ 'AI createNote config round-trip failed',
113
+ 'AI createNote config round-trips',
114
+ )
115
+
116
+ const aiEnduser = await sdk.api.endusers.createOne({ fname: 'AINoteTest' })
117
+ createdEnduserIds.push(aiEnduser.id)
118
+ const aiResult = await processFor(aiEnduser.id, aiInfo)
119
+ if (!aiResult.success) {
120
+ // bedrockAIAllowed/credits are org-level and unreadable here — accept the configuration error path
121
+ assert(
122
+ /bedrock|credit|enabled|ai/i.test(aiResult.error || ''),
123
+ `unexpected AI createNote error: ${aiResult.error}`,
124
+ 'AI createNote reports a configuration error (Bedrock unavailable — runtime generation skipped)',
125
+ )
126
+ } else {
127
+ const aiNotes = await sdk.api.notes.getSome({ filter: { enduserId: aiEnduser.id } })
128
+ assert(
129
+ aiNotes.length === 1 && !!aiNotes[0].text && aiNotes[0].title === 'AI note for AINoteTest',
130
+ 'AI-generated note missing or malformed',
131
+ 'AI-generated note created with non-empty text',
132
+ )
133
+ assert(aiNotes[0].aiGenerated === true, 'AI note missing aiGenerated flag', 'AI note flagged as AI-generated')
134
+ const conversations = await sdk.api.ai_conversations.getSome({ filter: { enduserId: aiEnduser.id } })
135
+ assert(
136
+ conversations.some(c => c.type === 'AI Note'),
137
+ 'no AI Note ai_conversations record',
138
+ 'AI generation logged to ai_conversations with type AI Note',
139
+ )
140
+ }
141
+ } finally {
142
+ await sdk.api.journeys.deleteOne(journey.id).catch(() => {})
143
+ for (const id of createdEnduserIds) {
144
+ await sdk.api.endusers.deleteOne(id).catch(() => {})
145
+ }
146
+ }
147
+ }
148
+
149
+ // Allow running this test file independently
150
+ if (require.main === module) {
151
+ const sdk = new Session({ host })
152
+ const sdkNonAdmin = new Session({ host })
153
+
154
+ const runTests = async () => {
155
+ await setup_tests(sdk, sdkNonAdmin)
156
+ await create_note_action_tests({ sdk, sdkNonAdmin })
157
+ }
158
+
159
+ runTests()
160
+ .then(() => {
161
+ console.log("✅ Create Note Action test suite completed successfully")
162
+ process.exit(0)
163
+ })
164
+ .catch((error) => {
165
+ console.error("❌ Create Note Action test suite failed:", error)
166
+ process.exit(1)
167
+ })
168
+ }
@@ -0,0 +1,161 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session, EnduserSession } from "../../sdk"
4
+ import { async_test, handleAnyError, passOnAnyResult, log_header } from "@tellescope/testing"
5
+ import { setup_tests } from "../setup"
6
+
7
+ const host = process.env.API_URL || 'http://localhost:8080' as const
8
+
9
+ // An enduser attendee can PATCH a calendar_event (needed to self-cancel out of a group event), and
10
+ // attendeeStatuses / cancelledGroupAttendees are attendee-keyed arrays that drive per-attendee
11
+ // automation triggers (handle_attendee_status_change_triggers + event-wide completion). The
12
+ // calendar_events relationship constraint must let an enduser touch ONLY their own id's entry, so a
13
+ // patient cannot fire or suppress a co-attendee's Appointment triggers by patching the shared event.
14
+ export const group_event_attendee_self_update_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
15
+ log_header("Group Event Attendee Self-Update Constraint")
16
+
17
+ const createdEnduserIds: string[] = []
18
+ const createdEventIds: string[] = []
19
+
20
+ const createEnduser = async (label: string) => {
21
+ const e = await sdk.api.endusers.createOne({ email: `attendee_self_update_${label}_${Date.now()}@test.tellescope.com` })
22
+ createdEnduserIds.push(e.id)
23
+ return e
24
+ }
25
+ const enduserSession = async (id: string) => {
26
+ const { authToken, enduser } = await sdk.api.endusers.generate_auth_token({ id })
27
+ return new EnduserSession({ host, authToken, enduser, businessId: sdk.userInfo.businessId })
28
+ }
29
+ const createGroupEvent = async (attendeeIds: string[]) => {
30
+ const event = await sdk.api.calendar_events.createOne({
31
+ title: 'Attendee Self-Update Test',
32
+ durationInMinutes: 30,
33
+ startTimeInMS: Date.now() + Math.floor(Math.random() * 100000),
34
+ attendees: attendeeIds.map(id => ({ type: 'enduser' as const, id })),
35
+ })
36
+ createdEventIds.push(event.id)
37
+ return event
38
+ }
39
+
40
+ try {
41
+ const attacker = await createEnduser('self')
42
+ const victim = await createEnduser('other')
43
+ const attackerSDK = await enduserSession(attacker.id)
44
+
45
+ // ---- ALLOWED: self-cancel out of a fresh group event (the portal self-cancel path) ----
46
+ const e1 = await createGroupEvent([attacker.id, victim.id])
47
+ await async_test(
48
+ "enduser may add their OWN cancelledGroupAttendees entry (self-cancel)",
49
+ () => attackerSDK.api.calendar_events.updateOne(e1.id, {
50
+ cancelledGroupAttendees: [{ id: attacker.id, at: new Date() }],
51
+ }, { replaceObjectFields: true }),
52
+ passOnAnyResult,
53
+ )
54
+
55
+ // ---- ALLOWED: set own attendeeStatuses entry ----
56
+ const e2 = await createGroupEvent([attacker.id, victim.id])
57
+ await async_test(
58
+ "enduser may set their OWN attendeeStatuses entry",
59
+ () => attackerSDK.api.calendar_events.updateOne(e2.id, {
60
+ attendeeStatuses: [{ id: attacker.id, at: new Date(), status: 'Cancelled' }],
61
+ }, { replaceObjectFields: true }),
62
+ passOnAnyResult,
63
+ )
64
+
65
+ // ---- REJECTED: set a co-attendee's attendeeStatuses (would fire that patient's triggers) ----
66
+ const e3 = await createGroupEvent([attacker.id, victim.id])
67
+ await async_test(
68
+ "enduser may NOT set a co-attendee's attendeeStatuses entry",
69
+ () => attackerSDK.api.calendar_events.updateOne(e3.id, {
70
+ attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'No-showed' }],
71
+ }, { replaceObjectFields: true }),
72
+ handleAnyError,
73
+ )
74
+
75
+ // ---- REJECTED: set a co-attendee's cancelledGroupAttendees entry ----
76
+ const e4 = await createGroupEvent([attacker.id, victim.id])
77
+ await async_test(
78
+ "enduser may NOT set a co-attendee's cancelledGroupAttendees entry",
79
+ () => attackerSDK.api.calendar_events.updateOne(e4.id, {
80
+ cancelledGroupAttendees: [{ id: victim.id, at: new Date() }],
81
+ }, { replaceObjectFields: true }),
82
+ handleAnyError,
83
+ )
84
+
85
+ // ---- REJECTED: drop a co-attendee's existing entry (would suppress their completion trigger) ----
86
+ const e5 = await createGroupEvent([attacker.id, victim.id])
87
+ await sdk.api.calendar_events.updateOne(e5.id, {
88
+ attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'Completed' }],
89
+ }, { replaceObjectFields: true })
90
+ await async_test(
91
+ "enduser may NOT remove a co-attendee's existing attendeeStatuses entry",
92
+ () => attackerSDK.api.calendar_events.updateOne(e5.id, {
93
+ attendeeStatuses: [{ id: attacker.id, at: new Date(), status: 'Cancelled' }], // silently drops victim's entry
94
+ }, { replaceObjectFields: true }),
95
+ handleAnyError,
96
+ )
97
+
98
+ // ---- REJECTED: modify a co-attendee's existing entry ----
99
+ const e6 = await createGroupEvent([attacker.id, victim.id])
100
+ await sdk.api.calendar_events.updateOne(e6.id, {
101
+ attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'Completed' }],
102
+ }, { replaceObjectFields: true })
103
+ await async_test(
104
+ "enduser may NOT modify a co-attendee's existing attendeeStatuses entry",
105
+ () => attackerSDK.api.calendar_events.updateOne(e6.id, {
106
+ attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'No-showed' }],
107
+ }, { replaceObjectFields: true }),
108
+ handleAnyError,
109
+ )
110
+
111
+ // ---- ALLOWED: self-cancel while PRESERVING a co-attendee's existing entry (realistic group self-cancel) ----
112
+ const e7 = await createGroupEvent([attacker.id, victim.id])
113
+ await sdk.api.calendar_events.updateOne(e7.id, {
114
+ cancelledGroupAttendees: [{ id: victim.id, at: new Date() }],
115
+ }, { replaceObjectFields: true })
116
+ const e7ForAttacker = await attackerSDK.api.calendar_events.getOne(e7.id)
117
+ await async_test(
118
+ "enduser may self-cancel while preserving a co-attendee's existing entry",
119
+ () => attackerSDK.api.calendar_events.updateOne(e7.id, {
120
+ cancelledGroupAttendees: [...(e7ForAttacker.cancelledGroupAttendees || []), { id: attacker.id, at: new Date() }],
121
+ }, { replaceObjectFields: true }),
122
+ passOnAnyResult,
123
+ )
124
+
125
+ // ---- ALLOWED (baseline): staff may set any attendee's status (constraint is enduser-only) ----
126
+ const e8 = await createGroupEvent([attacker.id, victim.id])
127
+ await async_test(
128
+ "staff may set any attendee's status",
129
+ () => sdk.api.calendar_events.updateOne(e8.id, {
130
+ attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'No-showed' }],
131
+ }, { replaceObjectFields: true }),
132
+ passOnAnyResult,
133
+ )
134
+
135
+ } finally {
136
+ for (const id of createdEventIds) { try { await sdk.api.calendar_events.deleteOne(id) } catch (_) {} }
137
+ for (const id of createdEnduserIds) { try { await sdk.api.endusers.deleteOne(id) } catch (_) {} }
138
+ }
139
+ }
140
+
141
+ // Allow running this test file independently
142
+ if (require.main === module) {
143
+ console.log(`🌐 Using API URL: ${host}`)
144
+ const sdk = new Session({ host })
145
+ const sdkNonAdmin = new Session({ host })
146
+
147
+ const runTests = async () => {
148
+ await setup_tests(sdk, sdkNonAdmin)
149
+ await group_event_attendee_self_update_tests({ sdk, sdkNonAdmin })
150
+ }
151
+
152
+ runTests()
153
+ .then(() => {
154
+ console.log("✅ Group event attendee self-update constraint tests completed successfully")
155
+ process.exit(0)
156
+ })
157
+ .catch((error) => {
158
+ console.error("❌ Group event attendee self-update constraint tests failed:", error)
159
+ process.exit(1)
160
+ })
161
+ }
@@ -88,20 +88,80 @@ export const develop_health_benefit_verification_rbac_tests = async ({ sdk, sdkN
88
88
  {
89
89
  shouldError: true,
90
90
  onError: (e: any) => {
91
- // Accept any 4xx access-denial response handler uses 403 (recommended).
92
- // Explicitly reject the pre-fix "Integration not found" / "Enduser is required" /
93
- // similar validation messages, which would indicate the access check never fired.
91
+ // Match the gate's exact message. The SDK error surfaces { message, info } (no top-level
92
+ // status/code), so we key on the message rather than the HTTP status. This is deliberately
93
+ // specific: a lapsed subscription ("Your subscription has expired") or an expired token
94
+ // ("Unauthenticated") also errors BEFORE the handler runs, but neither contains this string,
95
+ // so the test can't false-pass without the gate executing. Also reject the pre-fix
96
+ // downstream messages, which would indicate the access check never fired.
94
97
  const msg = (e?.message ?? '').toLowerCase()
95
- const status = e?.status ?? e?.code
96
98
  if (msg.includes('integration not found') || msg.includes('enduser is required')
97
99
  || msg.includes('organization') || msg.includes('provider')) {
98
100
  return false // reached past the access gate — regression
99
101
  }
100
- return status === 403 || status === 401
101
- || msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')
102
+ return msg.includes('access to this resource')
102
103
  },
103
104
  },
104
105
  )
106
+
107
+ // 4. POSITIVE case: a provider role WITH enduser access must PASS the gate and reach the
108
+ // integration stage (this test org has no Develop Health integration, so it 400s with
109
+ // "Integration not found"). This guards against the gate over-blocking legitimate users —
110
+ // the failure mode a future tightening (e.g. gating on 'All') would introduce.
111
+ const providerRoleName = `f0006-develop-health-provider-${Date.now()}`
112
+ let providerRbapId: string | undefined
113
+ try {
114
+ const providerRbap = await sdk.api.role_based_access_permissions.createOne({
115
+ role: providerRoleName,
116
+ permissions: { ...PROVIDER_PERMISSIONS }, // endusers read/create non-null, eligibility create non-null
117
+ })
118
+ providerRbapId = providerRbap.id
119
+
120
+ await sdk.api.users.updateOne(sdkNonAdmin.userInfo.id, { roles: [providerRoleName] }, { replaceObjectFields: true })
121
+ await wait(undefined, 1500)
122
+ await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!)
123
+
124
+ await async_test(
125
+ "F-0006: a provider role with enduser access passes the gate (reaches 'Integration not found', not 403)",
126
+ () => sdkNonAdmin.api.enduser_eligibility_results.develop_health_run_benefit_verification({
127
+ enduserId: new ObjectId().toHexString(),
128
+ providerUserId: new ObjectId().toHexString(),
129
+ insuranceType: 'primary',
130
+ diagnoses: [],
131
+ drugs: [{ name: 'Test Drug', dosage: '10mg', quantity: 1 }],
132
+ drug_history: { currently_taking_drugs: [], previously_taken_drugs: [] },
133
+ } as any),
134
+ {
135
+ shouldError: true,
136
+ onError: (e: any) => {
137
+ const msg = (e?.message ?? '').toLowerCase()
138
+ // Must NOT be the access-denial message (that would mean the gate wrongly blocked a
139
+ // provider). Reaching "Integration not found" (or org/provider validation) proves the
140
+ // gate passed and the read-scoped enduser load did not over-restrict a legitimate role.
141
+ if (msg.includes('access to this resource')) return false
142
+ return msg.includes('integration not found') || msg.includes('organization') || msg.includes('provider')
143
+ },
144
+ },
145
+ )
146
+ } finally {
147
+ if (providerRbapId) {
148
+ try {
149
+ await sdk.api.users.updateOne(sdkNonAdmin.userInfo.id, { roles: originalRoles ?? [] }, { replaceObjectFields: true })
150
+ } catch {}
151
+ try { await sdk.api.role_based_access_permissions.deleteOne(providerRbapId) } catch {}
152
+ await wait(undefined, 1500)
153
+ try { await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!) } catch {}
154
+ }
155
+ }
156
+
157
+ // NOTE — the create>read bypass (endusers.create broader than endusers.read) that the 2026-08-19
158
+ // read-scoping closes is NOT live-testable here: this org has no Develop Health integration, so
159
+ // the handler throws "Integration not found" BEFORE reaching the (now read-scoped) enduser load,
160
+ // and the enduser load is what the create>read fix governs. Exercising it would need either a real
161
+ // Develop Health integration or reordering the handler purely for the test. It is covered by code
162
+ // review + F-0166 (systemic follow-up). If a Develop Health sandbox integration is ever available
163
+ // in the test org, add: role { endusers: create 'All', read 'Assigned' } → call with a real
164
+ // enduser NOT assigned to the caller → expect the enduser-not-found path, not a successful forward.
105
165
  } finally {
106
166
  // Cleanup: restore original roles, delete the test role.
107
167
  try {
@@ -0,0 +1,152 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ wait,
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
+ // Unreachable webhook target — the request will fail quickly but the API still writes
14
+ // the resolved payload to webhook_logs, which is what we're verifying.
15
+ const WEBHOOK_TARGET_URL = 'http://127.0.0.1:9'
16
+
17
+ const find_log_for_url = async (sdk: Session, url: string) => {
18
+ const start = Date.now()
19
+ while (Date.now() - start < 10_000) {
20
+ const logs = await sdk.api.webhook_logs.getSome({ limit: 50, sort: 'newFirst' as any }) as Array<any>
21
+ const match = logs.find(l => l.url === url)
22
+ if (match) return match
23
+ await wait(undefined, 500)
24
+ }
25
+ return null
26
+ }
27
+
28
+ // Main test function that can be called independently
29
+ export const webhook_raw_json_number_tests = async (
30
+ { sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }
31
+ ) => {
32
+ log_header("Webhook Raw JSON Body Numeric Placeholder Tests")
33
+
34
+ const enduser = await sdk.api.endusers.createOne({ fname: 'RawJSON', lname: 'Number' })
35
+ const purchase = await sdk.api.purchases.createOne({
36
+ enduserId: enduser.id,
37
+ title: 'Raw JSON Number Test Purchase',
38
+ cost: { amount: 129.99, currency: 'USD' },
39
+ processor: 'Stripe',
40
+ })
41
+
42
+ // create an automation step to satisfy schema validation (automationStepId is required)
43
+ const journey = await sdk.api.journeys.createOne({
44
+ title: 'Webhook Raw JSON Number Tests',
45
+ defaultState: 'State 1',
46
+ })
47
+ const step = await sdk.api.automation_steps.createOne({
48
+ journeyId: journey.id,
49
+ events: [{ type: 'onJourneyStart', info: {} }],
50
+ action: {
51
+ type: 'sendWebhook',
52
+ info: {
53
+ message: 'placeholder',
54
+ url: WEBHOOK_TARGET_URL,
55
+ },
56
+ },
57
+ })
58
+
59
+ try {
60
+ await async_test(
61
+ 'Send Webhook rawJSONBody sends unquoted {{purchase.cost.amount}} as a number and quoted as a string',
62
+ async () => {
63
+ const url = `${WEBHOOK_TARGET_URL}/raw-json-number/${purchase.id}`
64
+ await sdk.api.webhooks.send_automation_webhook({
65
+ message: 'placeholder',
66
+ enduserId: enduser.id,
67
+ automationStepId: step.id,
68
+ action: {
69
+ type: 'sendWebhook',
70
+ info: {
71
+ message: 'placeholder',
72
+ url,
73
+ rawJSONBody: '{"amount": {{purchase.cost.amount}}, "asString": "{{purchase.cost.amount}}"}',
74
+ },
75
+ },
76
+ context: { purchaseId: purchase.id },
77
+ }).catch(() => {}) // the unreachable URL is expected to error after logging
78
+
79
+ const log = await find_log_for_url(sdk, url)
80
+ if (!log) return false
81
+ const payload = log.payload as any
82
+ return (
83
+ typeof payload?.amount === 'number' && payload.amount === 129.99
84
+ && typeof payload?.asString === 'string' && payload.asString === '129.99'
85
+ )
86
+ },
87
+ { onResult: r => r === true }
88
+ )
89
+
90
+ await async_test(
91
+ 'Send Webhook rawJSONBody with a MISSING unquoted placeholder degrades to null instead of dropping the whole body',
92
+ async () => {
93
+ const url = `${WEBHOOK_TARGET_URL}/raw-json-missing/${purchase.id}`
94
+ await sdk.api.webhooks.send_automation_webhook({
95
+ message: 'placeholder',
96
+ enduserId: enduser.id,
97
+ automationStepId: step.id,
98
+ action: {
99
+ type: 'sendWebhook',
100
+ info: {
101
+ message: 'placeholder',
102
+ url,
103
+ // enduser has no 'NoSuchNumber' field: the unquoted placeholder would otherwise leave a
104
+ // literal {{...}} in a value slot, invalidating the JSON and dropping the entire payload.
105
+ rawJSONBody: '{"missing": {{enduser.NoSuchNumber}}, "patient": "{{enduser.fname}}"}',
106
+ },
107
+ },
108
+ context: { purchaseId: purchase.id },
109
+ }).catch(() => {})
110
+
111
+ const log = await find_log_for_url(sdk, url)
112
+ if (!log) return false
113
+ const payload = log.payload as any
114
+ // body must still be sent (not null/empty), the missing value coerced to JSON null,
115
+ // and the resolvable placeholder still substituted
116
+ return (
117
+ payload && typeof payload === 'object'
118
+ && payload.missing === null
119
+ && payload.patient === 'RawJSON'
120
+ )
121
+ },
122
+ { onResult: r => r === true }
123
+ )
124
+ } finally {
125
+ try { await sdk.api.purchases.deleteOne(purchase.id) } catch {}
126
+ try { await sdk.api.automation_steps.deleteOne(step.id) } catch {}
127
+ try { await sdk.api.journeys.deleteOne(journey.id) } catch {}
128
+ try { await sdk.api.endusers.deleteOne(enduser.id) } catch {}
129
+ }
130
+ }
131
+
132
+ // Allow running this test file independently
133
+ if (require.main === module) {
134
+ console.log(`🌐 Using API URL: ${host}`)
135
+ const sdk = new Session({ host })
136
+ const sdkNonAdmin = new Session({ host })
137
+
138
+ const runTests = async () => {
139
+ await setup_tests(sdk, sdkNonAdmin)
140
+ await webhook_raw_json_number_tests({ sdk, sdkNonAdmin })
141
+ }
142
+
143
+ runTests()
144
+ .then(() => {
145
+ console.log("✅ Webhook raw JSON number test suite completed successfully")
146
+ process.exit(0)
147
+ })
148
+ .catch((error) => {
149
+ console.error("❌ Webhook raw JSON number test suite failed:", error)
150
+ process.exit(1)
151
+ })
152
+ }