@tellescope/sdk 1.256.1 → 1.256.2
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 +2 -2
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js +163 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js +354 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +205 -193
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts +3 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.js +96 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.js.map +1 -0
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +4 -5
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.js +156 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.js +347 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +205 -193
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/phone_validation.test.d.ts +3 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.js +93 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/appointment_link_expiration.test.ts +92 -0
- package/src/tests/api_tests/embeddables_auth_token.test.ts +417 -0
- package/src/tests/tests.ts +6 -0
- package/src/tests/unit_tests/phone_validation.test.ts +108 -0
- 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.
|
|
3
|
+
"version": "1.256.2",
|
|
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.
|
|
36
|
-
"@tellescope/schema": "1.256.
|
|
37
|
-
"@tellescope/testing": "1.256.
|
|
38
|
-
"@tellescope/types-client": "1.256.
|
|
39
|
-
"@tellescope/types-models": "1.256.
|
|
40
|
-
"@tellescope/types-utilities": "1.256.
|
|
41
|
-
"@tellescope/utilities": "1.256.
|
|
42
|
-
"@tellescope/validation": "1.256.
|
|
35
|
+
"@tellescope/constants": "1.256.2",
|
|
36
|
+
"@tellescope/schema": "1.256.2",
|
|
37
|
+
"@tellescope/testing": "1.256.2",
|
|
38
|
+
"@tellescope/types-client": "1.256.2",
|
|
39
|
+
"@tellescope/types-models": "1.256.2",
|
|
40
|
+
"@tellescope/types-utilities": "1.256.2",
|
|
41
|
+
"@tellescope/utilities": "1.256.2",
|
|
42
|
+
"@tellescope/validation": "1.256.2",
|
|
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": "
|
|
58
|
+
"gitHead": "a796b01e2a6ccc1f40aeb07084d8b8922e4d078e"
|
|
59
59
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
|
|
5
|
+
import { Session } from "../../sdk"
|
|
6
|
+
import { log_header, async_test } from "@tellescope/testing"
|
|
7
|
+
import { setup_tests } from "../setup"
|
|
8
|
+
|
|
9
|
+
const host = process.env.API_URL || 'http://localhost:8080'
|
|
10
|
+
|
|
11
|
+
const ONE_DAY_IN_SECONDS = 86400
|
|
12
|
+
const EVENT_DAYS_OUT = 120 // under the old expiry (event lead time + 4 weeks) this token lived ~148 days
|
|
13
|
+
|
|
14
|
+
// Decode a JWT payload WITHOUT verifying it — the signing key is server-side only, and the only
|
|
15
|
+
// thing under test is the lifetime the server stamped on the token.
|
|
16
|
+
const decode_jwt_payload = (token: string) => {
|
|
17
|
+
const [, payload] = token.split('.')
|
|
18
|
+
if (!payload) throw new Error("Not a JWT")
|
|
19
|
+
return JSON.parse(
|
|
20
|
+
Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
21
|
+
) as { iat: number, exp: number }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The API returns a /r/<token> shortlink, not the destination — follow one hop and pull the
|
|
25
|
+
// `token` query param out of the URL the patient actually lands on.
|
|
26
|
+
const resolve_redirect_token = async (link: string) => {
|
|
27
|
+
const { status, headers } = await axios.get(link, {
|
|
28
|
+
maxRedirects: 0,
|
|
29
|
+
validateStatus: () => true,
|
|
30
|
+
})
|
|
31
|
+
const location = headers['location']
|
|
32
|
+
if (!location) throw new Error(`Expected a redirect from ${link}, got status ${status}`)
|
|
33
|
+
|
|
34
|
+
const token = new URL(location).searchParams.get('token')
|
|
35
|
+
if (!token) throw new Error(`No token query param in redirect destination: ${location}`)
|
|
36
|
+
|
|
37
|
+
return token
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const appointment_link_expiration_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
41
|
+
log_header("Appointment Link Expiration")
|
|
42
|
+
|
|
43
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
44
|
+
email: `appointment-link-expiration-${Date.now()}@tellescope.com`,
|
|
45
|
+
})
|
|
46
|
+
const event = await sdk.api.calendar_events.createOne({
|
|
47
|
+
title: 'Appointment Link Expiration Test',
|
|
48
|
+
durationInMinutes: 30,
|
|
49
|
+
startTimeInMS: Date.now() + EVENT_DAYS_OUT * ONE_DAY_IN_SECONDS * 1000,
|
|
50
|
+
enableVideoCall: true,
|
|
51
|
+
attendees: [{ type: 'enduser', id: enduser.id }],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const { link } = await sdk.api.calendar_events.generate_meeting_link({
|
|
56
|
+
eventId: event.id,
|
|
57
|
+
enduserId: enduser.id,
|
|
58
|
+
})
|
|
59
|
+
const { iat, exp } = decode_jwt_payload(await resolve_redirect_token(link))
|
|
60
|
+
const lifetimeInSeconds = exp - iat
|
|
61
|
+
|
|
62
|
+
await async_test(
|
|
63
|
+
`meeting link token for an event ${EVENT_DAYS_OUT} days out expires within 24h`,
|
|
64
|
+
async () => lifetimeInSeconds,
|
|
65
|
+
{ onResult: (lifetime: number) => lifetime > 0 && lifetime <= ONE_DAY_IN_SECONDS },
|
|
66
|
+
)
|
|
67
|
+
} finally {
|
|
68
|
+
await sdk.api.calendar_events.deleteOne(event.id).catch(console.error)
|
|
69
|
+
await sdk.api.endusers.deleteOne(enduser.id).catch(console.error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Allow running this test file independently
|
|
74
|
+
if (require.main === module) {
|
|
75
|
+
const sdk = new Session({ host })
|
|
76
|
+
const sdkNonAdmin = new Session({ host })
|
|
77
|
+
|
|
78
|
+
const runTests = async () => {
|
|
79
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
80
|
+
await appointment_link_expiration_tests({ sdk, sdkNonAdmin })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
runTests()
|
|
84
|
+
.then(() => {
|
|
85
|
+
console.log("✅ Appointment link expiration test suite completed successfully")
|
|
86
|
+
process.exit(0)
|
|
87
|
+
})
|
|
88
|
+
.catch((error) => {
|
|
89
|
+
console.error("❌ Appointment link expiration test suite failed:", error)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
|
|
5
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
assert,
|
|
8
|
+
log_header,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import { setup_tests } from "../setup"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* {{enduser.embeddablesAuthToken}} — a 24h, path-confined enduser token that can be spliced into a
|
|
16
|
+
* third-party URL by a message author.
|
|
17
|
+
*
|
|
18
|
+
* The Embeddables handoff: Tellescope messages a patient a link to an Embeddables-hosted page with an
|
|
19
|
+
* enduser token in the URL; that page reads the patient's own record with it, refreshes it on load, and
|
|
20
|
+
* falls back to the existing OTP flow once it has expired.
|
|
21
|
+
*
|
|
22
|
+
* Everything here renders through POST /v1/templated-message, which runs the real
|
|
23
|
+
* process_message -> getTemplatedData path — the same code a real send uses, including the
|
|
24
|
+
* login_enduser minting branch. No messages are sent.
|
|
25
|
+
*
|
|
26
|
+
* Two facts the assertions rest on:
|
|
27
|
+
* - a BARE url/token is not shortened (only the {url}[text] syntax becomes a /r/<token> redirect), so
|
|
28
|
+
* the raw JWT lands in `plaintext` and is directly readable — unlike {{portal.link...}}, whose token
|
|
29
|
+
* the shortener swallows.
|
|
30
|
+
* - decoding needs no dependency: the test asserts on CLAIMS, never on signature validity.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const decodeJwt = (jwt: string) => {
|
|
34
|
+
const [, payload] = jwt.split('.')
|
|
35
|
+
if (!payload) throw new Error(`not a JWT: ${jwt}`)
|
|
36
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString()) as {
|
|
37
|
+
id: string,
|
|
38
|
+
type: string,
|
|
39
|
+
businessId: string,
|
|
40
|
+
sessionScopes?: string[],
|
|
41
|
+
iat: number,
|
|
42
|
+
exp: number,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const looksLikeJwt = (s: string) => s.split('.').length === 3 && s.split('.').every(p => !!p)
|
|
47
|
+
|
|
48
|
+
const SECONDS_IN_DAY = 86400
|
|
49
|
+
|
|
50
|
+
type ProbeMethod = 'get' | 'post' | 'patch' | 'delete'
|
|
51
|
+
|
|
52
|
+
export const embeddables_auth_token_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
53
|
+
log_header("Embeddables Auth Token Merge Field Tests")
|
|
54
|
+
|
|
55
|
+
const businessId = sdk.userInfo.businessId
|
|
56
|
+
|
|
57
|
+
// Probe with raw axios rather than the SDK: parseError (sdk/src/session.ts) discards the status code,
|
|
58
|
+
// and a confinement denial (403 Inaccessible) has to be told apart from a 401 or a permissions error.
|
|
59
|
+
const request = async (token: string, method: ProbeMethod, path: string, data?: any) => {
|
|
60
|
+
const r = await axios.request({
|
|
61
|
+
method,
|
|
62
|
+
url: `${host}/v1${path}`,
|
|
63
|
+
...method === 'get' ? { params: data } : { data },
|
|
64
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
65
|
+
validateStatus: () => true,
|
|
66
|
+
})
|
|
67
|
+
return { status: r.status, body: r.data }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
71
|
+
fname: 'Embeddables',
|
|
72
|
+
lname: 'Patient',
|
|
73
|
+
email: `embeddables-token-${Date.now()}@tellescope.com`,
|
|
74
|
+
phone: '+15555555555',
|
|
75
|
+
})
|
|
76
|
+
const otherEnduser = await sdk.api.endusers.createOne({
|
|
77
|
+
fname: 'Other',
|
|
78
|
+
lname: 'Patient',
|
|
79
|
+
email: `embeddables-other-${Date.now()}@tellescope.com`,
|
|
80
|
+
dateOfBirth: '01-01-1990',
|
|
81
|
+
})
|
|
82
|
+
// a tenant custom field with the same name as the merge field, to prove it cannot shadow it
|
|
83
|
+
const collisionEnduser = await sdk.api.endusers.createOne({
|
|
84
|
+
fname: 'Collision',
|
|
85
|
+
fields: { embeddablesAuthToken: 'SHOULD_NOT_APPEAR' },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
/* ------------------------------------------------------------------ *
|
|
90
|
+
* 5a. Minting and TTL, through the real templating pipeline
|
|
91
|
+
* ------------------------------------------------------------------ */
|
|
92
|
+
|
|
93
|
+
const render = async (message: string, o?: { enduserId?: string, caller?: Session }) => {
|
|
94
|
+
const caller = o?.caller ?? sdk
|
|
95
|
+
const r = await caller.api.templates.get_templated_message({
|
|
96
|
+
message,
|
|
97
|
+
enduserId: o?.enduserId ?? enduser.id,
|
|
98
|
+
userId: caller.userInfo.id,
|
|
99
|
+
channel: 'Chat',
|
|
100
|
+
})
|
|
101
|
+
return r.plaintext
|
|
102
|
+
}
|
|
103
|
+
const renderToken = async (message: string, o?: { enduserId?: string, caller?: Session }) => (
|
|
104
|
+
decodeJwt(await render(message, o))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const defaultToken = await renderToken('{{enduser.embeddablesAuthToken}}')
|
|
108
|
+
assert(
|
|
109
|
+
defaultToken.type === 'enduser',
|
|
110
|
+
`token type is '${defaultToken.type}', expected 'enduser'`,
|
|
111
|
+
'merge field mints an enduser token',
|
|
112
|
+
)
|
|
113
|
+
assert(
|
|
114
|
+
defaultToken.id === enduser.id,
|
|
115
|
+
`token id is '${defaultToken.id}', expected '${enduser.id}'`,
|
|
116
|
+
'token is bound to the templated enduser',
|
|
117
|
+
)
|
|
118
|
+
assert(
|
|
119
|
+
defaultToken.businessId === businessId,
|
|
120
|
+
`token businessId is '${defaultToken.businessId}', expected '${businessId}'`,
|
|
121
|
+
'token carries the correct businessId',
|
|
122
|
+
)
|
|
123
|
+
assert(
|
|
124
|
+
JSON.stringify(defaultToken.sessionScopes) === JSON.stringify(['embeddables-token']),
|
|
125
|
+
`sessionScopes is ${JSON.stringify(defaultToken.sessionScopes)}, expected ["embeddables-token"]`,
|
|
126
|
+
'token is confined to exactly the embeddables-token scope',
|
|
127
|
+
)
|
|
128
|
+
assert(
|
|
129
|
+
defaultToken.exp - defaultToken.iat === SECONDS_IN_DAY,
|
|
130
|
+
`default TTL is ${defaultToken.exp - defaultToken.iat}s, expected ${SECONDS_IN_DAY}s`,
|
|
131
|
+
'default TTL is 24h, matching {{portal.link...}}',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const shortToken = await renderToken('{{enduser.embeddablesAuthToken:3600}}')
|
|
135
|
+
assert(
|
|
136
|
+
shortToken.exp - shortToken.iat === 3600,
|
|
137
|
+
`configured TTL is ${shortToken.exp - shortToken.iat}s, expected 3600s`,
|
|
138
|
+
'a caller-specified TTL is honored',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// At MINT TIME only. The scope grants refresh, and refresh mints max(remaining, 4h) with no absolute
|
|
142
|
+
// ceiling, so a sub-4h TTL is floored to 4h on first refresh and the session can slide indefinitely.
|
|
143
|
+
// Inherited from how every enduser link token works (including the 24h {{portal.link}}) — see the
|
|
144
|
+
// comment at the cap in message_templating.ts. This asserts the cap, not a lifetime guarantee.
|
|
145
|
+
const cappedToken = await renderToken('{{enduser.embeddablesAuthToken:999999}}')
|
|
146
|
+
assert(
|
|
147
|
+
cappedToken.exp - cappedToken.iat === SECONDS_IN_DAY,
|
|
148
|
+
`capped TTL is ${cappedToken.exp - cappedToken.iat}s, expected ${SECONDS_IN_DAY}s`,
|
|
149
|
+
'the 24h cap holds — the minted TTL is configurable downward only',
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// garbage must fall back to the default: not NaN, not a thrown error, not an empty string
|
|
153
|
+
for (const ttl of ['abc', '-5', '0']) {
|
|
154
|
+
const fallback = await renderToken(`{{enduser.embeddablesAuthToken:${ttl}}}`)
|
|
155
|
+
assert(
|
|
156
|
+
fallback.exp - fallback.iat === SECONDS_IN_DAY,
|
|
157
|
+
`TTL ':${ttl}' produced ${fallback.exp - fallback.iat}s, expected a ${SECONDS_IN_DAY}s fallback`,
|
|
158
|
+
`an unparseable TTL (':${ttl}') falls back to 24h`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// the literal Embeddables use case: the token spliced into a third-party URL
|
|
163
|
+
const inURL = await render('https://example.com/?token={{enduser.embeddablesAuthToken}}')
|
|
164
|
+
const urlPrefix = 'https://example.com/?token='
|
|
165
|
+
assert(
|
|
166
|
+
inURL.startsWith(urlPrefix),
|
|
167
|
+
`rendered URL was '${inURL}'`,
|
|
168
|
+
'the token substitutes inline into an arbitrary URL',
|
|
169
|
+
)
|
|
170
|
+
assert(
|
|
171
|
+
!inURL.includes('/r/'),
|
|
172
|
+
`rendered URL was shortened: '${inURL}'`,
|
|
173
|
+
'a bare URL is not run through the link shortener',
|
|
174
|
+
)
|
|
175
|
+
assert(
|
|
176
|
+
decodeJwt(inURL.slice(urlPrefix.length)).id === enduser.id,
|
|
177
|
+
'the JWT spliced into the URL did not decode to this enduser',
|
|
178
|
+
'the JWT survives intact inside the URL',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const collisionRendered = await render(
|
|
182
|
+
'{{enduser.embeddablesAuthToken}}',
|
|
183
|
+
{ enduserId: collisionEnduser.id },
|
|
184
|
+
)
|
|
185
|
+
assert(
|
|
186
|
+
!collisionRendered.includes('SHOULD_NOT_APPEAR') && looksLikeJwt(collisionRendered),
|
|
187
|
+
`custom field shadowed the merge field: '${collisionRendered}'`,
|
|
188
|
+
'a tenant custom field named embeddablesAuthToken cannot shadow the merge field',
|
|
189
|
+
)
|
|
190
|
+
assert(
|
|
191
|
+
decodeJwt(collisionRendered).id === collisionEnduser.id,
|
|
192
|
+
'the collision-guard render did not decode to a JWT for that enduser',
|
|
193
|
+
'the collision-guard render is a real token',
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// the new field.split(':') runs on EVERY enduser merge field — a regression there would silently
|
|
197
|
+
// truncate ordinary field names, so pin that they still resolve
|
|
198
|
+
const mixed = await render('{{enduser.fname}}|{{enduser.embeddablesAuthToken}}')
|
|
199
|
+
const [renderedFname, mixedToken] = mixed.split('|')
|
|
200
|
+
assert(
|
|
201
|
+
renderedFname === 'Embeddables',
|
|
202
|
+
`expected fname 'Embeddables', got '${renderedFname}'`,
|
|
203
|
+
'ordinary enduser merge fields still resolve alongside the new one',
|
|
204
|
+
)
|
|
205
|
+
assert(
|
|
206
|
+
looksLikeJwt(mixedToken ?? ''),
|
|
207
|
+
`expected a JWT after the fname, got '${mixedToken}'`,
|
|
208
|
+
'the token still renders when combined with other fields',
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ *
|
|
212
|
+
* 5b. The scope grants exactly what it should
|
|
213
|
+
* ------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
const scopedAuthToken = await render('{{enduser.embeddablesAuthToken}}')
|
|
216
|
+
const scopedSession = new EnduserSession({ host, businessId, authToken: scopedAuthToken })
|
|
217
|
+
|
|
218
|
+
const ownRead = await request(scopedAuthToken, 'get', `/enduser/${enduser.id}`)
|
|
219
|
+
assert(
|
|
220
|
+
ownRead.status === 200 && ownRead.body?.id === enduser.id,
|
|
221
|
+
`own-record read returned ${ownRead.status}: ${JSON.stringify(ownRead.body)}`,
|
|
222
|
+
'the token reads its own enduser record (GET /enduser/:id)',
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
// getOne(id) is the singular read the SDK's findById routes to. NOT the bulk path —
|
|
226
|
+
// findById(id, { batch: true }) hits /endusers/bulk-read, a different grant that is deliberately
|
|
227
|
+
// outside this scope (see the trap documented at session_scopes.ts:33-38)
|
|
228
|
+
const ownReadViaSDK = await scopedSession.api.endusers.getOne(enduser.id)
|
|
229
|
+
assert(
|
|
230
|
+
ownReadViaSDK?.id === enduser.id,
|
|
231
|
+
`SDK getOne returned ${JSON.stringify(ownReadViaSDK)}`,
|
|
232
|
+
'the SDK singular-read path works with the token',
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const bulkRead = await request(scopedAuthToken, 'post', '/endusers/bulk-read', { ids: [enduser.id] })
|
|
236
|
+
assert(
|
|
237
|
+
bulkRead.status === 403,
|
|
238
|
+
`bulk-read returned ${bulkRead.status}, expected 403`,
|
|
239
|
+
'findById({ batch: true }) is NOT granted — bulk-read is a separate route',
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// ESCALATION CHECK. If the scope dropped on refresh, a 24h URL token would upgrade itself into a
|
|
243
|
+
// full portal session and the entire design would leak.
|
|
244
|
+
const refreshed = await scopedSession.refresh_session()
|
|
245
|
+
const refreshedClaims = decodeJwt(refreshed.authToken)
|
|
246
|
+
assert(
|
|
247
|
+
JSON.stringify(refreshedClaims.sessionScopes) === JSON.stringify(['embeddables-token']),
|
|
248
|
+
`refreshed token sessionScopes is ${JSON.stringify(refreshedClaims.sessionScopes)}`,
|
|
249
|
+
'refresh_session preserves the confinement — no escalation via refresh',
|
|
250
|
+
)
|
|
251
|
+
assert(
|
|
252
|
+
refreshedClaims.id === enduser.id,
|
|
253
|
+
`refreshed token id is '${refreshedClaims.id}'`,
|
|
254
|
+
'refresh_session returns a token for the same enduser',
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
// Each entry pins the status of the layer that actually stops it, because two different layers do.
|
|
258
|
+
// 403 Inaccessible is the scope confinement (checkAccess -> isPathAllowed) and is what everything an
|
|
259
|
+
// enduser session type can reach at all returns. load-inbox-data is staff-only — it has no entry in
|
|
260
|
+
// endusers.enduserActions, so its checkAccess is built with allowEnduser: false (routing.ts:913) and
|
|
261
|
+
// an enduser token is rejected as unauthenticated before the confinement is ever consulted. That 401
|
|
262
|
+
// is a stricter denial, not a weaker one; it is pinned as 401 so that a later change making the route
|
|
263
|
+
// enduser-reachable surfaces here rather than passing silently on the confinement fallback.
|
|
264
|
+
const denied: [string, ProbeMethod, string, any, number][] = [
|
|
265
|
+
// deliberately ungranted: it returns only the decoded token the caller already holds. Asserting
|
|
266
|
+
// the 403 means a future widening has to be a conscious edit.
|
|
267
|
+
['current_session_info', 'get', '/enduser-session-info', undefined, 403],
|
|
268
|
+
// no write grant at all — and email/phone are login channels (password reset resolves by email,
|
|
269
|
+
// OTP delivers to phone), so specifically confirm they cannot be repointed
|
|
270
|
+
['updateOne (fname)', 'patch', `/enduser/${enduser.id}`, { updates: { fname: 'Hacked' } }, 403],
|
|
271
|
+
['updateOne (email)', 'patch', `/enduser/${enduser.id}`, { updates: { email: 'attacker@example.com' } }, 403],
|
|
272
|
+
['updateOne (phone)', 'patch', `/enduser/${enduser.id}`, { updates: { phone: '+15550001111' } }, 403],
|
|
273
|
+
['chat_rooms.getSome', 'get', '/chat-rooms', undefined, 403],
|
|
274
|
+
['load_inbox_data', 'get', '/endusers/load-inbox-data', { limit: 10 }, 401],
|
|
275
|
+
['endusers.getSome (readMany)', 'get', '/endusers', undefined, 403],
|
|
276
|
+
// THE FILTER ORACLE. The bare singular-read route is the same handler as /enduser/:id, but with no
|
|
277
|
+
// path id it evaluates a caller-supplied filter against UNREDACTED records and answers 200 on match
|
|
278
|
+
// / 400 on miss — a per-field probe over every enduser in the business (applyRedactions strips the
|
|
279
|
+
// response body, never the filter). Both syntaxes are asserted because dropping mdbFilter alone
|
|
280
|
+
// would not close it: `filter`'s _gt/_lt/_in operators binary-search a value just as well. A 403
|
|
281
|
+
// here means the route is unreachable, not that one filter shape was rejected.
|
|
282
|
+
['bare read (filter oracle)', 'get', '/enduser', { filter: { fname: 'Other' } }, 403],
|
|
283
|
+
['bare read (mdbFilter oracle)', 'get', '/enduser', { mdbFilter: { fname: { $regex: '^Oth' } } }, 403],
|
|
284
|
+
]
|
|
285
|
+
for (const [label, method, path, data, expected] of denied) {
|
|
286
|
+
const r = await request(scopedAuthToken, method, path, data)
|
|
287
|
+
assert(
|
|
288
|
+
r.status === expected,
|
|
289
|
+
`${label} returned ${r.status}: ${JSON.stringify(r.body)} — expected ${expected}`,
|
|
290
|
+
`denied: ${label} (${expected})`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// the write really did not land
|
|
295
|
+
const afterWriteAttempts = await sdk.api.endusers.getOne(enduser.id)
|
|
296
|
+
assert(
|
|
297
|
+
afterWriteAttempts.fname === 'Embeddables' && afterWriteAttempts.phone === '+15555555555',
|
|
298
|
+
`record was mutated: ${JSON.stringify({ fname: afterWriteAttempts.fname, phone: afterWriteAttempts.phone })}`,
|
|
299
|
+
'no denied write reached the record',
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// CROSS-PATIENT READ. get:/enduser/:id cannot be pinned (the id lives in the token, not in
|
|
303
|
+
// scopeContext), so this is route-allowed and will not 403. What stops it is applyRedactions
|
|
304
|
+
// reducing a foreign enduser to { id, displayName, unredactedFields, unredactedTags }. This is the
|
|
305
|
+
// PHI-egress assertion — the one place the :id wildcard could bite.
|
|
306
|
+
const crossRead = await request(scopedAuthToken, 'get', `/enduser/${otherEnduser.id}`)
|
|
307
|
+
const leaked = ['email', 'phone', 'dateOfBirth', 'fields', 'fname', 'lname']
|
|
308
|
+
.filter(k => crossRead.status === 200 && crossRead.body?.[k] !== undefined)
|
|
309
|
+
assert(
|
|
310
|
+
leaked.length === 0,
|
|
311
|
+
`cross-patient read (status ${crossRead.status}) leaked: ${leaked.join(', ')}`,
|
|
312
|
+
'a cross-patient read is reduced to non-PHI display fields',
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
/* ------------------------------------------------------------------ *
|
|
316
|
+
* 5c. Invalidation behaves as recommended to Embeddables
|
|
317
|
+
*
|
|
318
|
+
* They were told to burn the URL token on page load via
|
|
319
|
+
* refresh_session({ invalidatePreviousToken: true }), so pin that contract.
|
|
320
|
+
* ------------------------------------------------------------------ */
|
|
321
|
+
|
|
322
|
+
const tokenA = await render('{{enduser.embeddablesAuthToken}}')
|
|
323
|
+
const sessionA = new EnduserSession({ host, businessId, authToken: tokenA })
|
|
324
|
+
|
|
325
|
+
const preBurn = await request(tokenA, 'get', `/enduser/${enduser.id}`)
|
|
326
|
+
assert(preBurn.status === 200, `token A read returned ${preBurn.status}`, 'token A reads its own record before the burn')
|
|
327
|
+
|
|
328
|
+
// a separately-minted session for the same enduser — the control proving the blocklist is
|
|
329
|
+
// per-credential and not a blanket logout that would kick the patient out of the portal
|
|
330
|
+
const { authToken: portalToken } = await sdk.api.endusers.generate_auth_token({ id: enduser.id })
|
|
331
|
+
|
|
332
|
+
const { authToken: tokenB } = await sessionA.refresh_session({ invalidatePreviousToken: true })
|
|
333
|
+
|
|
334
|
+
const postBurnA = await request(tokenA, 'get', `/enduser/${enduser.id}`)
|
|
335
|
+
assert(
|
|
336
|
+
postBurnA.status === 401,
|
|
337
|
+
`token A returned ${postBurnA.status} after the burn, expected 401`,
|
|
338
|
+
'invalidatePreviousToken blocklists token A (401)',
|
|
339
|
+
)
|
|
340
|
+
// note: the body here is plain text 'Unauthenticated', not JSON — do not JSON.parse it
|
|
341
|
+
|
|
342
|
+
const postBurnB = await request(tokenB, 'get', `/enduser/${enduser.id}`)
|
|
343
|
+
assert(
|
|
344
|
+
postBurnB.status === 200,
|
|
345
|
+
`token B returned ${postBurnB.status} after the burn, expected 200`,
|
|
346
|
+
'token B still works after the burn',
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
const postBurnPortal = await request(portalToken, 'get', `/enduser/${enduser.id}`)
|
|
350
|
+
assert(
|
|
351
|
+
postBurnPortal.status === 200,
|
|
352
|
+
`separately-minted session returned ${postBurnPortal.status}, expected 200`,
|
|
353
|
+
'the burn is a per-token blocklist, not a blanket logout of the patient',
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
/* ------------------------------------------------------------------ *
|
|
357
|
+
* 5d. The OTP fallback is still reachable from a dead token
|
|
358
|
+
*
|
|
359
|
+
* The whole Embeddables fallback rests on this: otp-methods only jwtDecodes the token and never
|
|
360
|
+
* verifies it, so expiry and invalidation are both irrelevant to it. Read-only, sends nothing.
|
|
361
|
+
*
|
|
362
|
+
* Deliberately not covered: send-otp-code / verify-otp-code. send_otp dispatches a real email/SMS,
|
|
363
|
+
* so it stays out of the automated suite — and neither endpoint is changed by this work.
|
|
364
|
+
* ------------------------------------------------------------------ */
|
|
365
|
+
|
|
366
|
+
const otpMethods = await new EnduserSession({ host, businessId }).api.endusers.get_otp_methods({ token: tokenA })
|
|
367
|
+
assert(
|
|
368
|
+
otpMethods.methods.includes('email') && otpMethods.methods.includes('sms'),
|
|
369
|
+
`otp methods for an invalidated token: ${JSON.stringify(otpMethods.methods)}`,
|
|
370
|
+
'otp-methods still resolves from an invalidated token (the OTP fallback stays reachable)',
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
/* ------------------------------------------------------------------ *
|
|
374
|
+
* 5e. Access control on rendering
|
|
375
|
+
*
|
|
376
|
+
* get_templated_message is not admin-gated; it requires templates.read + endusers.read, hand-checked
|
|
377
|
+
* in its handler. A non-admin rendering this field is INTENTIONAL — any staff user who can already
|
|
378
|
+
* render {{portal.link...}} through this endpoint can already mint a patient token, so the merge
|
|
379
|
+
* field adds no new privilege. Asserted so that decision stays visible rather than implicit.
|
|
380
|
+
* ------------------------------------------------------------------ */
|
|
381
|
+
|
|
382
|
+
const nonAdminToken = await renderToken('{{enduser.embeddablesAuthToken}}', { caller: sdkNonAdmin })
|
|
383
|
+
assert(
|
|
384
|
+
nonAdminToken.id === enduser.id
|
|
385
|
+
&& JSON.stringify(nonAdminToken.sessionScopes) === JSON.stringify(['embeddables-token']),
|
|
386
|
+
`non-admin render produced ${JSON.stringify(nonAdminToken)}`,
|
|
387
|
+
'a non-admin with templates.read + endusers.read can render the field (intentional)',
|
|
388
|
+
)
|
|
389
|
+
} finally {
|
|
390
|
+
await Promise.all([
|
|
391
|
+
sdk.api.endusers.deleteOne(enduser.id).catch(() => {}),
|
|
392
|
+
sdk.api.endusers.deleteOne(otherEnduser.id).catch(() => {}),
|
|
393
|
+
sdk.api.endusers.deleteOne(collisionEnduser.id).catch(() => {}),
|
|
394
|
+
])
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Allow running this test file independently
|
|
399
|
+
if (require.main === module) {
|
|
400
|
+
const sdk = new Session({ host })
|
|
401
|
+
const sdkNonAdmin = new Session({ host })
|
|
402
|
+
|
|
403
|
+
const runTests = async () => {
|
|
404
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
405
|
+
await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
runTests()
|
|
409
|
+
.then(() => {
|
|
410
|
+
console.log("✅ Embeddables auth token test suite completed successfully")
|
|
411
|
+
process.exit(0)
|
|
412
|
+
})
|
|
413
|
+
.catch((error) => {
|
|
414
|
+
console.error("❌ Embeddables auth token test suite failed:", error)
|
|
415
|
+
process.exit(1)
|
|
416
|
+
})
|
|
417
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { Session, APIQuery, EnduserSession } from "../sdk"
|
|
39
39
|
import { enduser_observations_acknowledge_tests } from "./api_tests/enduser_observations_acknowledge.test"
|
|
40
40
|
import { translations_tests } from "./api_tests/translations.test"
|
|
41
|
+
import { embeddables_auth_token_tests } from "./api_tests/embeddables_auth_token.test"
|
|
41
42
|
import { user_portal_settings_tests } from "./api_tests/user_portal_settings.test"
|
|
42
43
|
import { integrations_redacted_tests } from "./api_tests/integrations_redacted.test"
|
|
43
44
|
import { healthie_multi_integration_tests } from "./api_tests/healthie_multi_integration.test"
|
|
@@ -97,6 +98,7 @@ import { outbound_chat_sent_trigger_tests } from "./api_tests/outbound_chat_sent
|
|
|
97
98
|
import { time_tracks_tests, time_tracks_historical_tests, time_tracks_correction_tests, time_tracks_review_tests, time_tracks_lock_tests, time_tracks_edge_case_tests, time_tracks_resubmit_tests, time_tracks_appointment_duration_tests } from "./api_tests/time_tracks.test";
|
|
98
99
|
import { monthly_availability_restrictions_tests } from "./api_tests/monthly_availability_restrictions.test";
|
|
99
100
|
import { calendar_event_limits_tests } from "./api_tests/calendar_event_limits.test";
|
|
101
|
+
import { appointment_link_expiration_tests } from "./api_tests/appointment_link_expiration.test";
|
|
100
102
|
import { custom_aggregation_tests } from "./api_tests/custom_aggregation.test";
|
|
101
103
|
import { chats_analytics_tests } from "./api_tests/chats_analytics.test";
|
|
102
104
|
import { nutrition_tracking_tests } from "./api_tests/nutrition_tracking.test";
|
|
@@ -139,6 +141,7 @@ import { start_ai_conversation_tests } from "./api_tests/start_ai_conversation.t
|
|
|
139
141
|
import { dont_sync_to_elation_form_submission_tests } from "./api_tests/dont_sync_to_elation_form_submission.test";
|
|
140
142
|
import { medication_added_trigger_tests } from "./api_tests/medication_added_trigger.test";
|
|
141
143
|
import { conditional_logic_medication_unit_tests } from "./unit_tests/conditional_logic_medication.test";
|
|
144
|
+
import { phone_validation_unit_tests } from "./unit_tests/phone_validation.test";
|
|
142
145
|
import { elation_user_id_tests } from "./api_tests/elation_user_id.test";
|
|
143
146
|
import { organization_settings_duplicates_tests } from "./api_tests/organization_settings_duplicates.test";
|
|
144
147
|
import { calendar_events_bulk_update_tests } from "./api_tests/calendar_events_bulk_update.test";
|
|
@@ -15274,10 +15277,12 @@ const ip_address_form_tests = async () => {
|
|
|
15274
15277
|
|
|
15275
15278
|
await enduser_conditional_logic_tests()
|
|
15276
15279
|
await conditional_logic_medication_unit_tests()
|
|
15280
|
+
await phone_validation_unit_tests()
|
|
15277
15281
|
await replace_enduser_template_values_tests()
|
|
15278
15282
|
await replace_form_field_template_values_tests()
|
|
15279
15283
|
await mfa_tests()
|
|
15280
15284
|
await setup_tests(sdk, sdkNonAdmin)
|
|
15285
|
+
await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
|
|
15281
15286
|
await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
|
|
15282
15287
|
await webhook_outbound_ssrf_tests({ sdk, sdkNonAdmin })
|
|
15283
15288
|
await allowed_paths_confinement_tests({ sdk, sdkNonAdmin })
|
|
@@ -15346,6 +15351,7 @@ const ip_address_form_tests = async () => {
|
|
|
15346
15351
|
await time_tracks_resubmit_tests({ sdk, sdkNonAdmin })
|
|
15347
15352
|
await time_tracks_appointment_duration_tests({ sdk, sdkNonAdmin })
|
|
15348
15353
|
await calendar_event_limits_tests({ sdk, sdkNonAdmin })
|
|
15354
|
+
await appointment_link_expiration_tests({ sdk, sdkNonAdmin })
|
|
15349
15355
|
await get_some_projection_tests({ sdk, sdkNonAdmin })
|
|
15350
15356
|
await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
|
|
15351
15357
|
await elation_user_id_tests({ sdk, sdkNonAdmin })
|