@tellescope/sdk 1.256.9 → 1.256.11
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/generate_suggested_reply.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/readonly-enduser-preview.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/readonly-enduser-preview.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/readonly-enduser-preview.test.js +472 -0
- package/lib/cjs/tests/api_tests/security/readonly-enduser-preview.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.d.ts +26 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.js +336 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.js +205 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +236 -203
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/generate_suggested_reply.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/readonly-enduser-preview.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/readonly-enduser-preview.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/readonly-enduser-preview.test.js +465 -0
- package/lib/esm/tests/api_tests/security/readonly-enduser-preview.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.d.ts +26 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.js +332 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.js.map +1 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.js +201 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +236 -203
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/security/readonly-enduser-preview.test.ts +301 -0
- package/src/tests/api_tests/security/suggested-reply-redaction.test.ts +232 -0
- package/src/tests/api_tests/stedi_approval.test.ts +167 -0
- package/src/tests/tests.ts +34 -13
- 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.11",
|
|
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.11",
|
|
36
|
+
"@tellescope/schema": "1.256.11",
|
|
37
|
+
"@tellescope/testing": "1.256.11",
|
|
38
|
+
"@tellescope/types-client": "1.256.11",
|
|
39
|
+
"@tellescope/types-models": "1.256.11",
|
|
40
|
+
"@tellescope/types-utilities": "1.256.11",
|
|
41
|
+
"@tellescope/utilities": "1.256.11",
|
|
42
|
+
"@tellescope/validation": "1.256.11",
|
|
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": "e0b2785223de7100a93651f4cf6f501223103054"
|
|
59
59
|
}
|
|
@@ -0,0 +1,301 @@
|
|
|
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 { PROVIDER_PERMISSIONS, NO_ACCESS } from "@tellescope/constants"
|
|
11
|
+
import { setup_tests } from "../../setup"
|
|
12
|
+
|
|
13
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
14
|
+
|
|
15
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
16
|
+
|
|
17
|
+
const ALL = 'All' as const
|
|
18
|
+
const READ_ONLY = { create: NO_ACCESS, read: ALL, update: NO_ACCESS, delete: NO_ACCESS }
|
|
19
|
+
const FULL_ACCESS = { create: ALL, read: ALL, update: ALL, delete: ALL }
|
|
20
|
+
const ASSIGNED_UPDATE = { create: NO_ACCESS, read: ALL, update: 'Assigned' as const, delete: NO_ACCESS }
|
|
21
|
+
|
|
22
|
+
const PORTAL_PREVIEW_SCOPE = 'portal-preview'
|
|
23
|
+
|
|
24
|
+
type ProbeMethod = 'get' | 'post' | 'patch' | 'put' | 'delete'
|
|
25
|
+
|
|
26
|
+
// A single collected check. Everything is gathered first and asserted at the end, because the
|
|
27
|
+
// testing harness exits the process on the first failed assert — collecting first means a RED run
|
|
28
|
+
// prints the full evidence table before the first vulnerability assert trips the exit.
|
|
29
|
+
type Check = {
|
|
30
|
+
label: string,
|
|
31
|
+
pass: boolean,
|
|
32
|
+
detail: string,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Regression test for F-0138
|
|
37
|
+
* (security-audit/findings/F-0138-endusers-generate-auth-token-no-admin-gate.md).
|
|
38
|
+
*
|
|
39
|
+
* `endusers.generate_auth_token` (the "View portal as patient" preview flow) requires only
|
|
40
|
+
* `endusers.read`, yet mints a FULLY-PRIVILEGED enduser session. A staff user who cannot update a
|
|
41
|
+
* patient can therefore act AS that patient: edit the record, submit forms, send chats, book
|
|
42
|
+
* appointments, even set the portal password.
|
|
43
|
+
*
|
|
44
|
+
* The fix mints the preview token with `sessionScopes: ['portal-preview']` whenever the calling
|
|
45
|
+
* staff user lacks update access to that specific enduser (record-level, honoring `Assigned`
|
|
46
|
+
* scoping). The scope's allowlist (api/modules/session_scopes.ts) grants exactly the portal's read
|
|
47
|
+
* surface plus session maintenance; every write path 403s via existing path confinement.
|
|
48
|
+
*
|
|
49
|
+
* RED (pre-fix): section A fails — the response carries no sessionScopes and every write in A2
|
|
50
|
+
* SUCCEEDS. Sections D/E (controls) pass pre-fix. GREEN (post-fix): all sections pass.
|
|
51
|
+
*
|
|
52
|
+
* Probes use raw axios rather than the SDK: parseError (sdk/src/session.ts) discards the status
|
|
53
|
+
* code, and a path-confinement denial (403 "Inaccessible") is byte-identical in message to a
|
|
54
|
+
* permissions denial. Write/blocked-read probes additionally confirm an UNCONFINED enduser token
|
|
55
|
+
* gets a non-403 on the same path in the same run, so a 403 can only be confinement, never bad
|
|
56
|
+
* permissions or a bad body.
|
|
57
|
+
*/
|
|
58
|
+
export const readonly_enduser_preview_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
59
|
+
log_header("F-0138: portal-preview scope for read-only staff generate_auth_token")
|
|
60
|
+
|
|
61
|
+
const checks: Check[] = []
|
|
62
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
63
|
+
|
|
64
|
+
const request = async (token: string, method: ProbeMethod, path: string, data?: any) => {
|
|
65
|
+
const r = await axios.request({
|
|
66
|
+
method,
|
|
67
|
+
url: `${host}/v1${path}`,
|
|
68
|
+
...method === 'get' ? { params: data } : { data },
|
|
69
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
70
|
+
validateStatus: () => true,
|
|
71
|
+
})
|
|
72
|
+
return { status: r.status, body: r.data }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isPathDenial = (r: { status: number, body: any }) => (
|
|
76
|
+
r.status === 403 && r.body?.message === 'Inaccessible'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// The confined token must be denied `path` by confinement, while an unconfined baseline is not.
|
|
80
|
+
const expectDenied = async (
|
|
81
|
+
label: string, confinedToken: string, baselineToken: string,
|
|
82
|
+
method: ProbeMethod, path: string, data?: any,
|
|
83
|
+
) => {
|
|
84
|
+
const baseline = await request(baselineToken, method, path, data)
|
|
85
|
+
const confined = await request(confinedToken, method, path, data)
|
|
86
|
+
const badProbe = baseline.status === 403
|
|
87
|
+
record(
|
|
88
|
+
label,
|
|
89
|
+
!badProbe && isPathDenial(confined),
|
|
90
|
+
`${method}:${path} confined=${confined.status} baseline=${baseline.status}` + (badProbe ? ' BAD PROBE: baseline also 403' : ''),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The path must not be blocked by confinement (neither 403 nor 401).
|
|
95
|
+
const expectAllowed = async (
|
|
96
|
+
label: string, token: string, method: ProbeMethod, path: string, data?: any,
|
|
97
|
+
) => {
|
|
98
|
+
const r = await request(token, method, path, data)
|
|
99
|
+
record(label, r.status !== 403 && r.status !== 401, `${method}:${path} status=${r.status}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hasScope = (enduser: any) => !!enduser?.sessionScopes?.includes?.(PORTAL_PREVIEW_SCOPE)
|
|
103
|
+
|
|
104
|
+
const readonlyRole = `f0138-readonly-${RAND()}`
|
|
105
|
+
const fullUpdateRole = `f0138-full-${RAND()}`
|
|
106
|
+
const assignedUpdateRole = `f0138-assigned-${RAND()}`
|
|
107
|
+
const roleIds: string[] = []
|
|
108
|
+
const userIds: string[] = []
|
|
109
|
+
const enduserIds: string[] = []
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
/* ================= fixtures ================= */
|
|
113
|
+
|
|
114
|
+
for (const [role, endusersPerm] of [
|
|
115
|
+
[readonlyRole, READ_ONLY],
|
|
116
|
+
[fullUpdateRole, FULL_ACCESS],
|
|
117
|
+
[assignedUpdateRole, ASSIGNED_UPDATE],
|
|
118
|
+
] as const) {
|
|
119
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
120
|
+
role,
|
|
121
|
+
permissions: { ...PROVIDER_PERMISSIONS, endusers: endusersPerm },
|
|
122
|
+
} as any)
|
|
123
|
+
roleIds.push(rbap.id)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Throwaway staff users. verifiedEmail must be set at create (rejected on update).
|
|
127
|
+
const mkUser = async (role: string) => {
|
|
128
|
+
const u = await sdk.api.users.createOne({
|
|
129
|
+
email: `f0138-${role}-${RAND()}@tellescope.example`,
|
|
130
|
+
fname: 'F0138', lname: 'Staff',
|
|
131
|
+
verifiedEmail: true,
|
|
132
|
+
notificationEmailsDisabled: true,
|
|
133
|
+
roles: [role],
|
|
134
|
+
} as any)
|
|
135
|
+
userIds.push(u.id)
|
|
136
|
+
return u
|
|
137
|
+
}
|
|
138
|
+
const readonlyUser = await mkUser(readonlyRole)
|
|
139
|
+
const fullUpdateUser = await mkUser(fullUpdateRole)
|
|
140
|
+
const assignedUpdateUser = await mkUser(assignedUpdateRole)
|
|
141
|
+
|
|
142
|
+
// Staff sessions from admin-minted tokens (avoids the password / login-rate-limit dance).
|
|
143
|
+
const staffSession = async (userId: string) => {
|
|
144
|
+
const { authToken } = await sdk.api.users.generate_auth_token({ id: userId })
|
|
145
|
+
return new Session({ host, authToken })
|
|
146
|
+
}
|
|
147
|
+
const readonlyStaff = await staffSession(readonlyUser.id)
|
|
148
|
+
const fullUpdateStaff = await staffSession(fullUpdateUser.id)
|
|
149
|
+
const assignedUpdateStaff = await staffSession(assignedUpdateUser.id)
|
|
150
|
+
|
|
151
|
+
// Endusers: unassigned patient, a bystander, and one assigned to the assigned-update user.
|
|
152
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
153
|
+
email: `f0138-patient-${RAND()}@tellescope.example`, fname: 'F0138', lname: 'Patient',
|
|
154
|
+
})
|
|
155
|
+
enduserIds.push(enduser.id)
|
|
156
|
+
const assignedEnduser = await sdk.api.endusers.createOne({
|
|
157
|
+
email: `f0138-assigned-${RAND()}@tellescope.example`, fname: 'F0138', lname: 'Assigned',
|
|
158
|
+
assignedTo: [assignedUpdateUser.id],
|
|
159
|
+
} as any)
|
|
160
|
+
enduserIds.push(assignedEnduser.id)
|
|
161
|
+
|
|
162
|
+
// A form_response the patient owns, for the submit-form-response write probe.
|
|
163
|
+
const form = await sdk.api.forms.createOne({ title: `F-0138 preview form ${RAND()}` })
|
|
164
|
+
const { response: formResponse } = await sdk.api.form_responses.prepare_form_response({
|
|
165
|
+
formId: form.id, enduserId: enduser.id,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
/* ===== A1: read-only staff mints a scoped preview token ===== */
|
|
169
|
+
|
|
170
|
+
const previewMint = await readonlyStaff.api.endusers.generate_auth_token({ id: enduser.id })
|
|
171
|
+
const previewToken = previewMint.authToken
|
|
172
|
+
record('A1: read-only staff receives an authToken', !!previewToken, `authToken present=${!!previewToken}`)
|
|
173
|
+
record(
|
|
174
|
+
'A1: read-only staff mint is scoped to portal-preview',
|
|
175
|
+
hasScope(previewMint.enduser),
|
|
176
|
+
`sessionScopes=${JSON.stringify((previewMint.enduser as any)?.sessionScopes)} (F-0138 vuln if absent)`,
|
|
177
|
+
)
|
|
178
|
+
record(
|
|
179
|
+
'A1(E): scoped preview token carries denySocket',
|
|
180
|
+
(previewMint.enduser as any)?.denySocket === true,
|
|
181
|
+
`denySocket=${(previewMint.enduser as any)?.denySocket}`,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
// Unconfined enduser baseline (admin-minted) — proves the scoped token's 403s are confinement.
|
|
185
|
+
const { authToken: baselineEnduserToken } = await sdk.api.endusers.generate_auth_token({ id: enduser.id })
|
|
186
|
+
|
|
187
|
+
/* ===== A2: writes blocked for the scoped session ===== */
|
|
188
|
+
|
|
189
|
+
await expectDenied('A2: cannot self-update the patient record', previewToken, baselineEnduserToken,
|
|
190
|
+
'patch', `/enduser/${enduser.id}`, { updates: { fname: 'Hacked' } })
|
|
191
|
+
await expectDenied('A2: cannot set the patient portal password', previewToken, baselineEnduserToken,
|
|
192
|
+
'post', '/set-enduser-password', { id: enduser.id, password: `F0138Pw!${RAND()}` })
|
|
193
|
+
await expectDenied('A2: cannot send a chat as the patient', previewToken, baselineEnduserToken,
|
|
194
|
+
'post', '/chat', { message: 'hi from preview' })
|
|
195
|
+
await expectDenied('A2: cannot submit a form as the patient', previewToken, baselineEnduserToken,
|
|
196
|
+
'patch', '/submit-form-response', { id: formResponse.id, responses: [] })
|
|
197
|
+
await expectDenied('A2: cannot book an appointment as the patient', previewToken, baselineEnduserToken,
|
|
198
|
+
'post', '/book-appointment', {})
|
|
199
|
+
await expectDenied('A2: cannot start a file upload as the patient', previewToken, baselineEnduserToken,
|
|
200
|
+
'post', '/prepare-file-upload', { name: 'x.pdf', type: 'application/pdf', enduserId: enduser.id })
|
|
201
|
+
|
|
202
|
+
/* ===== B: no laundering via refresh ===== */
|
|
203
|
+
|
|
204
|
+
const refreshed = await request(previewToken, 'post', '/refresh-enduser-session', {})
|
|
205
|
+
const refreshedToken = refreshed.body?.authToken as string | undefined
|
|
206
|
+
record('B: refresh succeeds', refreshed.status === 200 && !!refreshedToken, `status=${refreshed.status} token=${!!refreshedToken}`)
|
|
207
|
+
record(
|
|
208
|
+
'B: refreshed session still carries portal-preview scope',
|
|
209
|
+
hasScope(refreshed.body?.enduser),
|
|
210
|
+
`sessionScopes=${JSON.stringify(refreshed.body?.enduser?.sessionScopes)} (laundering if absent)`,
|
|
211
|
+
)
|
|
212
|
+
if (refreshedToken) {
|
|
213
|
+
await expectDenied('B: refreshed token still cannot write', refreshedToken, baselineEnduserToken,
|
|
214
|
+
'patch', `/enduser/${enduser.id}`, { updates: { fname: 'HackedAfterRefresh' } })
|
|
215
|
+
}
|
|
216
|
+
await expectAllowed('B: logout allowed', previewToken, 'post', '/logout-enduser')
|
|
217
|
+
|
|
218
|
+
/* ===== C: ungranted mutating "reads" blocked ===== */
|
|
219
|
+
|
|
220
|
+
// Re-mint (the previous token was just logged out).
|
|
221
|
+
const cMint = await readonlyStaff.api.endusers.generate_auth_token({ id: enduser.id })
|
|
222
|
+
const cToken = cMint.authToken
|
|
223
|
+
await expectDenied('C: proxy_read blocked', cToken, baselineEnduserToken,
|
|
224
|
+
'get', '/integrations/proxy-read', { integration: 'Stripe', type: 'subscriptions' })
|
|
225
|
+
await expectDenied('C: booking_info (mints a token) blocked', cToken, baselineEnduserToken,
|
|
226
|
+
'get', '/form-fields/booking-info', { bookingPageId: 'x' })
|
|
227
|
+
await expectDenied('C: form_responses stripe_details blocked', cToken, baselineEnduserToken,
|
|
228
|
+
'get', '/form-responses/stripe-details', {})
|
|
229
|
+
await expectDenied('C: products stripe-portal-session blocked', cToken, baselineEnduserToken,
|
|
230
|
+
'get', '/products/stripe-portal-session', {})
|
|
231
|
+
|
|
232
|
+
/* ===== D: granted portal reads still work ===== */
|
|
233
|
+
|
|
234
|
+
await expectAllowed('D: portal-customizations', cToken, 'get', '/portal-customizations')
|
|
235
|
+
await expectAllowed('D: calendar-events', cToken, 'get', '/calendar-events')
|
|
236
|
+
await expectAllowed('D: my-meetings', cToken, 'get', '/my-meetings')
|
|
237
|
+
await expectAllowed('D: chat-rooms', cToken, 'get', '/chat-rooms')
|
|
238
|
+
await expectAllowed('D: care-plans', cToken, 'get', '/care-plans')
|
|
239
|
+
await expectAllowed('D: form-responses', cToken, 'get', '/form-responses')
|
|
240
|
+
await expectAllowed('D: self enduser read', cToken, 'get', `/enduser/${enduser.id}`)
|
|
241
|
+
await expectAllowed('D: file-download-URL', cToken, 'get', '/file-download-URL', { secureName: 'nonexistent' })
|
|
242
|
+
await expectAllowed('D: enduser-session-info', cToken, 'get', '/enduser-session-info')
|
|
243
|
+
|
|
244
|
+
/* ===== E: zero behavior change controls ===== */
|
|
245
|
+
|
|
246
|
+
const adminMint = await sdk.api.endusers.generate_auth_token({ id: enduser.id })
|
|
247
|
+
record('E: admin-minted token is NOT scoped', !hasScope(adminMint.enduser),
|
|
248
|
+
`sessionScopes=${JSON.stringify((adminMint.enduser as any)?.sessionScopes)}`)
|
|
249
|
+
await expectAllowed('E: admin-minted token can write', adminMint.authToken, 'patch',
|
|
250
|
+
`/enduser/${enduser.id}`, { updates: { fname: 'AdminEditOk' } })
|
|
251
|
+
|
|
252
|
+
const fullMint = await fullUpdateStaff.api.endusers.generate_auth_token({ id: enduser.id })
|
|
253
|
+
record('E: update:All staff mints an UNSCOPED token', !hasScope(fullMint.enduser),
|
|
254
|
+
`sessionScopes=${JSON.stringify((fullMint.enduser as any)?.sessionScopes)}`)
|
|
255
|
+
|
|
256
|
+
const assignedMint = await assignedUpdateStaff.api.endusers.generate_auth_token({ id: assignedEnduser.id })
|
|
257
|
+
record('E: assigned-update staff UNSCOPED for an assigned patient', !hasScope(assignedMint.enduser),
|
|
258
|
+
`sessionScopes=${JSON.stringify((assignedMint.enduser as any)?.sessionScopes)}`)
|
|
259
|
+
|
|
260
|
+
const assignedMintUnassigned = await assignedUpdateStaff.api.endusers.generate_auth_token({ id: enduser.id })
|
|
261
|
+
record('E: assigned-update staff SCOPED for an unassigned patient', hasScope(assignedMintUnassigned.enduser),
|
|
262
|
+
`sessionScopes=${JSON.stringify((assignedMintUnassigned.enduser as any)?.sessionScopes)}`)
|
|
263
|
+
|
|
264
|
+
/* ===== report: print full table first, then assert (harness exits on first failure) ===== */
|
|
265
|
+
console.log('\n F-0138 check summary:')
|
|
266
|
+
for (const c of checks) {
|
|
267
|
+
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
268
|
+
}
|
|
269
|
+
console.log('')
|
|
270
|
+
|
|
271
|
+
for (const c of checks) {
|
|
272
|
+
assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
for (const id of enduserIds) { try { await sdk.api.endusers.deleteOne(id) } catch {} }
|
|
276
|
+
for (const id of userIds) { try { await sdk.api.users.deleteOne(id) } catch {} }
|
|
277
|
+
for (const id of roleIds) { try { await sdk.api.role_based_access_permissions.deleteOne(id) } catch {} }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Allow running this test file independently
|
|
282
|
+
if (require.main === module) {
|
|
283
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
284
|
+
const sdk = new Session({ host })
|
|
285
|
+
const sdkNonAdmin = new Session({ host })
|
|
286
|
+
|
|
287
|
+
const runTests = async () => {
|
|
288
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
289
|
+
await readonly_enduser_preview_tests({ sdk, sdkNonAdmin })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
runTests()
|
|
293
|
+
.then(() => {
|
|
294
|
+
console.log("✅ F-0138 portal-preview scope test suite completed successfully")
|
|
295
|
+
process.exit(0)
|
|
296
|
+
})
|
|
297
|
+
.catch((error) => {
|
|
298
|
+
console.error("❌ F-0138 portal-preview scope test suite failed:", error)
|
|
299
|
+
process.exit(1)
|
|
300
|
+
})
|
|
301
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../../sdk"
|
|
4
|
+
import { schema } from "@tellescope/schema"
|
|
5
|
+
import { async_test, assert, log_header } from "@tellescope/testing"
|
|
6
|
+
import { setup_tests } from "../../setup"
|
|
7
|
+
|
|
8
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
9
|
+
|
|
10
|
+
const DRAFT = 'STAFF-ONLY-DRAFT-do-not-show-the-patient'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Security regressions for the generateSuggestedReply journey step.
|
|
14
|
+
*
|
|
15
|
+
* `suggestedReply` holds a staff-facing draft that may be AI-generated and unreviewed, built from the
|
|
16
|
+
* patient's own chart context. Four defects were fixed:
|
|
17
|
+
*
|
|
18
|
+
* 1. emails / sms_messages / group_mms_conversations `suggestedReply` had no `redactions: ['enduser']`
|
|
19
|
+
* (only the sibling `suggestedReplyIsAIGenerated` flag did, so content leaked but the "machine
|
|
20
|
+
* generated" marker was stripped).
|
|
21
|
+
* 2. POST /v1/bulk-actions/read admitted enduser sessions via schema.users.enduserActions.readMany but
|
|
22
|
+
* never checked the REQUESTED model's enduserActions — so any model in the enduser query map was
|
|
23
|
+
* readable, regardless of whether it grants endusers read.
|
|
24
|
+
* 3. chat_rooms.mark_read spread the whole room with no applyRedactions, bypassing the redaction that
|
|
25
|
+
* *was* declared. The patient portal calls mark_read on every chat view.
|
|
26
|
+
* 4. generateSuggestedReply accepted an OUTBOUND message as its target (journeyContext carries outbound
|
|
27
|
+
* ids for the Message Delivery Failure and Message Link Clicked triggers).
|
|
28
|
+
*
|
|
29
|
+
* NO BEDROCK: drafts are written directly with a staff updateOne, and the fix-4 case returns from the
|
|
30
|
+
* inbound guard before the context load / Bedrock call are reached. Deterministic, consumes no AI credits.
|
|
31
|
+
*/
|
|
32
|
+
export const suggested_reply_redaction_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
33
|
+
log_header("Suggested Reply redaction + inbound guard")
|
|
34
|
+
|
|
35
|
+
let enduserId: string | undefined
|
|
36
|
+
let emailId: string | undefined
|
|
37
|
+
let inboundSMSId: string | undefined
|
|
38
|
+
let outboundSMSId: string | undefined
|
|
39
|
+
let roomId: string | undefined
|
|
40
|
+
let journeyId: string | undefined
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
/*************** Fixtures ***************/
|
|
44
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
45
|
+
fname: 'SRRedaction', lname: 'Test', phone: '+15555550188',
|
|
46
|
+
email: `sr-redaction-${Date.now()}@example.com`,
|
|
47
|
+
})
|
|
48
|
+
enduserId = enduser.id
|
|
49
|
+
|
|
50
|
+
const email = await sdk.api.emails.createOne({
|
|
51
|
+
enduserId, subject: 'SR seed', textContent: 'seed', logOnly: true, inbound: true,
|
|
52
|
+
} as any)
|
|
53
|
+
emailId = email.id
|
|
54
|
+
|
|
55
|
+
const inboundSMS = await sdk.api.sms_messages.createOne({
|
|
56
|
+
enduserId, message: 'inbound seed', logOnly: true, inbound: true,
|
|
57
|
+
} as any)
|
|
58
|
+
inboundSMSId = inboundSMS.id
|
|
59
|
+
|
|
60
|
+
const outboundSMS = await sdk.api.sms_messages.createOne({
|
|
61
|
+
enduserId, message: 'outbound seed', logOnly: true, inbound: false,
|
|
62
|
+
} as any)
|
|
63
|
+
outboundSMSId = outboundSMS.id
|
|
64
|
+
|
|
65
|
+
const room = await sdk.api.chat_rooms.createOne({
|
|
66
|
+
type: 'internal', userIds: [], enduserIds: [enduserId], title: 'SR redaction room',
|
|
67
|
+
})
|
|
68
|
+
roomId = room.id
|
|
69
|
+
|
|
70
|
+
for (const [model, id] of [['emails', emailId], ['sms_messages', inboundSMSId], ['chat_rooms', roomId]] as const) {
|
|
71
|
+
await (sdk.api[model] as any).updateOne(id, { suggestedReply: DRAFT, suggestedReplyIsAIGenerated: true })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// POSITIVE CONTROL — a field the server's schema doesn't know is silently dropped on write and
|
|
75
|
+
// reads as absent, which would make every "absent for enduser" assertion below pass spuriously
|
|
76
|
+
// against a stale server. Prove the values actually persisted and are visible to staff first.
|
|
77
|
+
const roomAsAdmin = await sdk.api.chat_rooms.getOne(roomId)
|
|
78
|
+
const emailAsAdmin = await sdk.api.emails.getOne(emailId)
|
|
79
|
+
const smsAsAdmin = await sdk.api.sms_messages.getOne(inboundSMSId)
|
|
80
|
+
assert(
|
|
81
|
+
roomAsAdmin.suggestedReply === DRAFT && roomAsAdmin.suggestedReplyIsAIGenerated === true
|
|
82
|
+
&& emailAsAdmin.suggestedReply === DRAFT && smsAsAdmin.suggestedReply === DRAFT,
|
|
83
|
+
'drafts did not persist / are not visible to staff — the API server is likely running a stale '
|
|
84
|
+
+ '@tellescope/schema, so the redaction assertions below would pass spuriously. Restart it.',
|
|
85
|
+
'CONTROL: drafts persist and staff can read them on all three models',
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/*************** Enduser session ***************/
|
|
89
|
+
// generate_auth_token rather than .authenticate, to stay clear of login rate limits
|
|
90
|
+
const { authToken } = await sdk.api.endusers.generate_auth_token({ id: enduserId })
|
|
91
|
+
const asEnduser = new Session({ host })
|
|
92
|
+
asEnduser.setAuthToken(authToken)
|
|
93
|
+
|
|
94
|
+
/*************** Fix 3: mark_read must redact ***************/
|
|
95
|
+
await async_test(
|
|
96
|
+
'chat_rooms.mark_read does not leak suggestedReply to the patient',
|
|
97
|
+
() => asEnduser.api.chat_rooms.mark_read({ id: roomId as string }),
|
|
98
|
+
{
|
|
99
|
+
onResult: (r: any) => {
|
|
100
|
+
const updated = r?.updated
|
|
101
|
+
if (!updated) return false
|
|
102
|
+
// unreadCount proves the handler still did its job rather than erroring out
|
|
103
|
+
const stillWorks = updated.infoForUser?.[enduserId as string]?.unreadCount === 0
|
|
104
|
+
return stillWorks
|
|
105
|
+
&& updated.suggestedReply === undefined
|
|
106
|
+
&& updated.suggestedReplyIsAIGenerated === undefined
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
/*************** Fix 1: plain reads on chat_rooms must redact ***************/
|
|
112
|
+
const roomAsEnduser = await asEnduser.api.chat_rooms.getOne(roomId)
|
|
113
|
+
assert(
|
|
114
|
+
roomAsEnduser.suggestedReply === undefined && roomAsEnduser.suggestedReplyIsAIGenerated === undefined,
|
|
115
|
+
`enduser read chat_rooms.suggestedReply: ${roomAsEnduser.suggestedReply}`,
|
|
116
|
+
'chat_rooms.getOne redacts suggestedReply for endusers',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const roomsAsEnduser = await asEnduser.api.chat_rooms.getSome({ limit: 50 })
|
|
120
|
+
const foundRoom = roomsAsEnduser.find(r => r.id === roomId)
|
|
121
|
+
assert(
|
|
122
|
+
!!foundRoom && foundRoom.suggestedReply === undefined,
|
|
123
|
+
'enduser readMany leaked chat_rooms.suggestedReply',
|
|
124
|
+
'chat_rooms.getSome redacts suggestedReply for endusers',
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
/*************** Fix 2: bulk-read must gate per requested model ***************/
|
|
128
|
+
const bulk = await (asEnduser as any).bulk_load({
|
|
129
|
+
load: [{ model: 'emails' }, { model: 'sms_messages' }, { model: 'chat_rooms' }],
|
|
130
|
+
})
|
|
131
|
+
assert(
|
|
132
|
+
bulk.results[0] === null && bulk.results[1] === null,
|
|
133
|
+
`bulk_load exposed models without enduser readMany: emails=${JSON.stringify(bulk.results[0])} sms=${JSON.stringify(bulk.results[1])}`,
|
|
134
|
+
'bulk_load returns null for models with no enduser readMany (emails, sms_messages)',
|
|
135
|
+
)
|
|
136
|
+
assert(
|
|
137
|
+
!!bulk.results[2]?.records && !bulk.results[2].records.some((r: any) => r.suggestedReply !== undefined),
|
|
138
|
+
'bulk_load leaked chat_rooms.suggestedReply',
|
|
139
|
+
'bulk_load still returns chat_rooms for endusers, with suggestedReply redacted',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// no staff regression — the gate must only affect enduser sessions
|
|
143
|
+
const bulkAsStaff = await sdk.bulk_load({ load: [{ model: 'emails' }] })
|
|
144
|
+
assert(
|
|
145
|
+
!!bulkAsStaff.results[0]?.records && bulkAsStaff.results[0].records.length > 0,
|
|
146
|
+
'bulk_load stopped returning emails for staff — the enduser gate leaked into staff sessions',
|
|
147
|
+
'bulk_load unchanged for staff sessions',
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
/*************** Fix 1 (defense-in-depth): redaction is declared in schema ***************/
|
|
151
|
+
// Once the bulk-read gate is in place, emails/sms_messages are unreachable by enduser sessions, so
|
|
152
|
+
// their redaction is no longer observable end-to-end. Assert it structurally so it survives the
|
|
153
|
+
// gate ever being relaxed.
|
|
154
|
+
for (const model of ['emails', 'sms_messages', 'group_mms_conversations'] as const) {
|
|
155
|
+
const field = (schema as any)[model].fields.suggestedReply
|
|
156
|
+
assert(
|
|
157
|
+
!!field?.redactions?.includes('enduser'),
|
|
158
|
+
`${model}.suggestedReply is missing redactions: ['enduser']`,
|
|
159
|
+
`${model}.suggestedReply declares an enduser redaction`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/*************** Fix 4: outbound target must be rejected without writing ***************/
|
|
164
|
+
const journey = await sdk.api.journeys.createOne({ title: `SR Inbound Guard ${Date.now()}` })
|
|
165
|
+
journeyId = journey.id
|
|
166
|
+
const step = await sdk.api.automation_steps.createOne({
|
|
167
|
+
journeyId,
|
|
168
|
+
events: [{ type: 'onJourneyStart', info: {} }],
|
|
169
|
+
action: { type: 'generateSuggestedReply', info: {} },
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const automatedAction = await sdk.api.automated_actions.createOne({
|
|
173
|
+
enduserId,
|
|
174
|
+
journeyId,
|
|
175
|
+
automationStepId: step.id,
|
|
176
|
+
event: { type: 'onJourneyStart', info: {} },
|
|
177
|
+
action: { type: 'generateSuggestedReply', info: {} },
|
|
178
|
+
status: 'active',
|
|
179
|
+
processAfter: Date.now(),
|
|
180
|
+
journeyContext: { smsId: outboundSMSId },
|
|
181
|
+
} as any)
|
|
182
|
+
// returns before loadAISummaryContextFromDB / Bedrock.sendMessage — no AI credits consumed
|
|
183
|
+
const processResult = await sdk.api.automated_actions.process({ automatedActionId: automatedAction.id })
|
|
184
|
+
|
|
185
|
+
const outboundAfter = await sdk.api.sms_messages.getOne(outboundSMSId)
|
|
186
|
+
assert(
|
|
187
|
+
!outboundAfter.suggestedReply,
|
|
188
|
+
`a draft was written onto an OUTBOUND message: ${outboundAfter.suggestedReply}`,
|
|
189
|
+
'outbound target is rejected without writing suggestedReply',
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// success:false is what makes the worker throw — which marks the automated_action 'error', fires
|
|
193
|
+
// any onError branch, honors continueOnError, and logs the background_error. Returning
|
|
194
|
+
// success:true here would report a clean run and silently skip all of that. Asserted on the
|
|
195
|
+
// response rather than on background_errors because this calls the API directly, so the worker
|
|
196
|
+
// (which writes that row) is not in the loop.
|
|
197
|
+
assert(
|
|
198
|
+
(processResult as any).success === false
|
|
199
|
+
&& /outbound message, not an incoming one/i.test((processResult as any).error ?? ''),
|
|
200
|
+
`expected success:false with an outbound-specific error, got: ${JSON.stringify(processResult)}`,
|
|
201
|
+
'outbound rejection returns success:false so journey error handling engages',
|
|
202
|
+
)
|
|
203
|
+
} finally {
|
|
204
|
+
if (journeyId) { try { await sdk.api.journeys.deleteOne(journeyId) } catch {} } // cascades to steps
|
|
205
|
+
if (roomId) { try { await sdk.api.chat_rooms.deleteOne(roomId) } catch {} }
|
|
206
|
+
if (emailId) { try { await sdk.api.emails.deleteOne(emailId) } catch {} }
|
|
207
|
+
if (inboundSMSId) { try { await sdk.api.sms_messages.deleteOne(inboundSMSId) } catch {} }
|
|
208
|
+
if (outboundSMSId) { try { await sdk.api.sms_messages.deleteOne(outboundSMSId) } catch {} }
|
|
209
|
+
if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (require.main === module) {
|
|
214
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
215
|
+
const sdk = new Session({ host })
|
|
216
|
+
const sdkNonAdmin = new Session({ host })
|
|
217
|
+
|
|
218
|
+
const runTests = async () => {
|
|
219
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
220
|
+
await suggested_reply_redaction_tests({ sdk, sdkNonAdmin })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
runTests()
|
|
224
|
+
.then(() => {
|
|
225
|
+
console.log("✅ Suggested Reply redaction test suite completed successfully")
|
|
226
|
+
process.exit(0)
|
|
227
|
+
})
|
|
228
|
+
.catch((error) => {
|
|
229
|
+
console.error("❌ Suggested Reply redaction test suite failed:", error)
|
|
230
|
+
process.exit(1)
|
|
231
|
+
})
|
|
232
|
+
}
|