@tellescope/sdk 1.255.17 → 1.255.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cjs/sdk.d.ts +7 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +215 -112
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +773 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +211 -178
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +1 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +10 -4
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +1 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +215 -112
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +766 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +211 -178
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
- package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
- package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
- package/src/tests/api_tests/scoped_api_keys.test.ts +87 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +506 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
- package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
- package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
- package/src/tests/tests.ts +20 -1
- package/test_generated.pdf +0 -0
- package/.env +0 -13
|
@@ -0,0 +1,506 @@
|
|
|
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
|
+
* Regression test for F-0166 — allowedPaths wildcard disables path confinement entirely.
|
|
16
|
+
*
|
|
17
|
+
* Several public, unauthenticated endpoints mint a JWT confined to a small set of API paths via an
|
|
18
|
+
* `allowedPaths` claim. The enforcement predicate (api/modules/authentication.ts checkAccess)
|
|
19
|
+
* contained:
|
|
20
|
+
*
|
|
21
|
+
* || (p.endsWith('*') && req.path.split('/')[0] === p.split('/')[0])
|
|
22
|
+
*
|
|
23
|
+
* Every request path begins with '/', so req.path.split('/')[0] is ''. Every wildcard entry also
|
|
24
|
+
* begins with '/', so p.split('/')[0] is ''. The clause reduces to '' === '' — always true. Any
|
|
25
|
+
* allowedPaths array containing a single '*' entry therefore matched EVERY request path and the
|
|
26
|
+
* 403 never fired.
|
|
27
|
+
*
|
|
28
|
+
* Two mint sites contain `/meeting/*`, and both are public and unauthenticated:
|
|
29
|
+
* - session_for_join_link — enduser session, MFA bypassed
|
|
30
|
+
* - session_for_start_link — full STAFF session with mfa: false
|
|
31
|
+
*
|
|
32
|
+
* So an unauthenticated caller holding a link token had an effectively unrestricted session.
|
|
33
|
+
*
|
|
34
|
+
* Fixing that exposed a second defect in the same primitive: entries matched by bare string prefix
|
|
35
|
+
* with no notion of HTTP method, so '/users' and '/endusers' were collection-NAMESPACE grants (22
|
|
36
|
+
* and 21 routes each). The worst consequence was a full escape — PATCH /users/consent called
|
|
37
|
+
* login_user on a freshly-read DB record with no options, minting a token with no allowedPaths at
|
|
38
|
+
* all, so two requests turned a start link into an unconfined staff session.
|
|
39
|
+
*
|
|
40
|
+
* Fix, in two parts:
|
|
41
|
+
* - entries are now method-scoped and exact ('get:/endusers'), with ':param' for segments not
|
|
42
|
+
* knowable at mint time ('get:/meeting/:id'). Un-prefixed and '*' entries remain honoured only
|
|
43
|
+
* so tokens minted before this grammar keep working until they expire.
|
|
44
|
+
* - consent / configure_inbox / switch_account forward req.session.allowedPaths into login_user.
|
|
45
|
+
*
|
|
46
|
+
* Scenarios:
|
|
47
|
+
* S1 public-form session — no wildcard; correctly confined throughout (regression anchor)
|
|
48
|
+
* S2 join-link session — RED before the wildcard fix
|
|
49
|
+
* S3 start-link session — RED before the wildcard fix (staff session; the severe case)
|
|
50
|
+
* S4 side-effects of the /calendar-events entry
|
|
51
|
+
* S6 namespace grants and the privilege-escalation chain — RED before method-scoped exact matching
|
|
52
|
+
* S5 ordinary sessions with no allowedPaths claim, incl. headless API-key callers, are unaffected
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
56
|
+
const OBJECT_ID = '68a1b2c3d4e5f60718293a4b'
|
|
57
|
+
|
|
58
|
+
type ProbeMethod = 'get' | 'post' | 'patch' | 'put' | 'delete'
|
|
59
|
+
|
|
60
|
+
type Probe = {
|
|
61
|
+
scenario: string,
|
|
62
|
+
label: string,
|
|
63
|
+
path: string,
|
|
64
|
+
expected: 'allowed' | 'denied',
|
|
65
|
+
status: number,
|
|
66
|
+
baselineStatus?: number,
|
|
67
|
+
pass: boolean,
|
|
68
|
+
note?: string,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
72
|
+
log_header("F-0166: allowedPaths wildcard bypasses session path confinement")
|
|
73
|
+
|
|
74
|
+
const probes: Probe[] = []
|
|
75
|
+
|
|
76
|
+
// Probe with raw axios rather than the SDK: parseError (sdk/src/session.ts) discards the status
|
|
77
|
+
// code, and a path-confinement denial is byte-identical to a permissions denial.
|
|
78
|
+
const request = async (token: string, method: ProbeMethod, path: string, data?: any) => {
|
|
79
|
+
const r = await axios.request({
|
|
80
|
+
method,
|
|
81
|
+
url: `${host}/v1${path}`,
|
|
82
|
+
...method === 'get' ? { params: data } : { data },
|
|
83
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
84
|
+
validateStatus: () => true,
|
|
85
|
+
})
|
|
86
|
+
return { status: r.status, body: r.data }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isPathDenial = (r: { status: number, body: any }) => (
|
|
90
|
+
r.status === 403 && r.body?.message === 'Inaccessible'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A confined session must be denied `path`.
|
|
95
|
+
*
|
|
96
|
+
* First confirms an UNCONFINED session of the same type gets a non-403 on the same path in the
|
|
97
|
+
* same run. Without that, a 403 could just as easily be an ordinary permissions denial and the
|
|
98
|
+
* test would pass for the wrong reason.
|
|
99
|
+
*/
|
|
100
|
+
const expectDenied = async (
|
|
101
|
+
scenario: string, label: string, confinedToken: string, baselineToken: string,
|
|
102
|
+
method: ProbeMethod, path: string, data?: any,
|
|
103
|
+
) => {
|
|
104
|
+
const baseline = await request(baselineToken, method, path, data)
|
|
105
|
+
const confined = await request(confinedToken, method, path, data)
|
|
106
|
+
|
|
107
|
+
probes.push({
|
|
108
|
+
scenario,
|
|
109
|
+
label,
|
|
110
|
+
path,
|
|
111
|
+
expected: 'denied',
|
|
112
|
+
status: confined.status,
|
|
113
|
+
baselineStatus: baseline.status,
|
|
114
|
+
pass: baseline.status !== 403 && isPathDenial(confined),
|
|
115
|
+
note: baseline.status === 403 ? 'BAD PROBE: baseline session also got 403' : undefined,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The path must not be blocked by confinement.
|
|
121
|
+
*
|
|
122
|
+
* Asserts the status is neither 403 (path denied) nor 401 (unauthenticated) rather than === 200,
|
|
123
|
+
* so a 404 from a nonexistent id or a 400 from a stub body is not a false failure. Rejecting 401
|
|
124
|
+
* matters: without it a probe that never reached the handler would pass as "allowed".
|
|
125
|
+
* `allow401` opts out for paths the session legitimately cannot authenticate against.
|
|
126
|
+
*/
|
|
127
|
+
const expectAllowed = async (
|
|
128
|
+
scenario: string, label: string, token: string,
|
|
129
|
+
method: ProbeMethod, path: string, data?: any,
|
|
130
|
+
{ allow401 = false }: { allow401?: boolean } = {},
|
|
131
|
+
) => {
|
|
132
|
+
const r = await request(token, method, path, data)
|
|
133
|
+
probes.push({
|
|
134
|
+
scenario,
|
|
135
|
+
label,
|
|
136
|
+
path,
|
|
137
|
+
expected: 'allowed',
|
|
138
|
+
status: r.status,
|
|
139
|
+
pass: r.status !== 403 && (allow401 || r.status !== 401),
|
|
140
|
+
note: allow401 && r.status === 401 ? '401 expected: enduser cannot authenticate this model' : undefined,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let formId: string | undefined
|
|
145
|
+
let productId: string | undefined
|
|
146
|
+
let joinEnduserId: string | undefined
|
|
147
|
+
let formEnduserEmail: string | undefined
|
|
148
|
+
let hostUserId: string | undefined
|
|
149
|
+
const eventIds: string[] = []
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
/* ================= fixtures ================= */
|
|
153
|
+
|
|
154
|
+
const businessId = sdk.userInfo.businessId
|
|
155
|
+
|
|
156
|
+
// -- public form (no wildcard in its allowedPaths)
|
|
157
|
+
const form = await sdk.api.forms.createOne({
|
|
158
|
+
title: `F-0166 path confinement ${RAND()}`,
|
|
159
|
+
allowPublicURL: true,
|
|
160
|
+
})
|
|
161
|
+
formId = form.id
|
|
162
|
+
await sdk.api.form_fields.createOne({
|
|
163
|
+
formId: form.id,
|
|
164
|
+
title: 'q',
|
|
165
|
+
type: 'string',
|
|
166
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
167
|
+
})
|
|
168
|
+
formEnduserEmail = `f0166-form-${RAND()}@tellescope.example`
|
|
169
|
+
const publicFormSession = await new EnduserSession({ host, businessId })
|
|
170
|
+
.api.form_responses.session_for_public_form({
|
|
171
|
+
businessId,
|
|
172
|
+
formId: form.id,
|
|
173
|
+
email: formEnduserEmail,
|
|
174
|
+
fname: 'Path',
|
|
175
|
+
lname: 'Confinement',
|
|
176
|
+
skipMatch: true, // also skips the intake-phone requirement
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const product = await sdk.api.products.createOne({
|
|
180
|
+
title: `F-0166 product ${RAND()}`,
|
|
181
|
+
cost: { amount: 500, currency: 'USD' },
|
|
182
|
+
processor: 'Stripe',
|
|
183
|
+
} as any)
|
|
184
|
+
productId = product.id
|
|
185
|
+
|
|
186
|
+
// -- join link (enduser session, allowedPaths contains /meeting/*)
|
|
187
|
+
const joinEnduser = await sdk.api.endusers.createOne({
|
|
188
|
+
email: `f0166-join-${RAND()}@tellescope.example`,
|
|
189
|
+
})
|
|
190
|
+
joinEnduserId = joinEnduser.id
|
|
191
|
+
|
|
192
|
+
const joinLinkToken = `f0166-join-${RAND()}${RAND()}`
|
|
193
|
+
const joinEvent = await sdk.api.calendar_events.createOne({
|
|
194
|
+
title: 'F-0166 join', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
195
|
+
attendees: [{ type: 'enduser', id: joinEnduser.id, joinLinkToken }],
|
|
196
|
+
} as any)
|
|
197
|
+
eventIds.push(joinEvent.id)
|
|
198
|
+
|
|
199
|
+
const unrelatedEvent = await sdk.api.calendar_events.createOne({
|
|
200
|
+
title: 'F-0166 unrelated', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
201
|
+
attendees: [{ type: 'enduser', id: joinEnduser.id }],
|
|
202
|
+
} as any)
|
|
203
|
+
eventIds.push(unrelatedEvent.id)
|
|
204
|
+
|
|
205
|
+
const joinSession = await new EnduserSession({ host, businessId })
|
|
206
|
+
.api.calendar_events.session_for_join_link({ token: joinLinkToken })
|
|
207
|
+
|
|
208
|
+
// -- start link (STAFF session, allowedPaths contains /meeting/*)
|
|
209
|
+
// throwaway user; verifiedEmail must be set at create time (it is rejected on update)
|
|
210
|
+
const hostUser = await sdk.api.users.createOne({
|
|
211
|
+
email: `f0166-host-${RAND()}@tellescope.example`,
|
|
212
|
+
fname: 'Path', lname: 'Host',
|
|
213
|
+
verifiedEmail: true,
|
|
214
|
+
notificationEmailsDisabled: true,
|
|
215
|
+
} as any)
|
|
216
|
+
hostUserId = hostUser.id
|
|
217
|
+
|
|
218
|
+
const startLinkToken = `f0166-start-${RAND()}${RAND()}`
|
|
219
|
+
const startEvent = await sdk.api.calendar_events.createOne({
|
|
220
|
+
title: 'F-0166 start', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
221
|
+
attendees: [{ type: 'user', id: hostUser.id }, { type: 'enduser', id: joinEnduser.id }],
|
|
222
|
+
startLinkToken,
|
|
223
|
+
} as any)
|
|
224
|
+
eventIds.push(startEvent.id)
|
|
225
|
+
|
|
226
|
+
const startSession = await sdk.api.calendar_events.session_for_start_link({ token: startLinkToken })
|
|
227
|
+
|
|
228
|
+
// -- unconfined baselines of each session type (generate_auth_token, never .authenticate)
|
|
229
|
+
const { authToken: baselineEnduserToken } = await sdk.api.endusers.generate_auth_token({ id: joinEnduser.id })
|
|
230
|
+
const { authToken: baselineUserToken } = await sdk.api.users.generate_auth_token({ id: hostUser.id })
|
|
231
|
+
|
|
232
|
+
/* ============ S1: public-form session — no wildcard, correctly confined already ============ */
|
|
233
|
+
|
|
234
|
+
const S1 = 'S1 public-form'
|
|
235
|
+
await expectAllowed(S1, 'always-allowed path', publicFormSession.authToken, 'get', '/test-authenticated')
|
|
236
|
+
await expectAllowed(S1, 'listed: form info for access code', publicFormSession.authToken, 'get', '/form-info-for-access-code', { accessCode: publicFormSession.accessCode })
|
|
237
|
+
await expectAllowed(S1, 'listed: refresh enduser session', publicFormSession.authToken, 'post', '/refresh-enduser-session')
|
|
238
|
+
await expectAllowed(S1, 'listed: product by id', publicFormSession.authToken, 'get', `/product/${productId}`)
|
|
239
|
+
|
|
240
|
+
await expectDenied(S1, 'unlisted: calendar events', publicFormSession.authToken, baselineEnduserToken, 'get', '/calendar-events')
|
|
241
|
+
await expectDenied(S1, 'unlisted: form responses', publicFormSession.authToken, baselineEnduserToken, 'get', '/form-responses')
|
|
242
|
+
await expectDenied(S1, 'unlisted: files', publicFormSession.authToken, baselineEnduserToken, 'get', '/files')
|
|
243
|
+
|
|
244
|
+
// 'get:/product' names one route, so it no longer leaks the whole /products namespace the way a
|
|
245
|
+
// bare '/product' prefix entry did — that used to reach /products/prepare-stripe-checkout too
|
|
246
|
+
await expectDenied(S1, 'exact matching: /products not granted by get:/product', publicFormSession.authToken, baselineEnduserToken, 'get', '/products')
|
|
247
|
+
|
|
248
|
+
/* ============ S2: join-link session — RED before the fix ============ */
|
|
249
|
+
|
|
250
|
+
const S2 = 'S2 join-link (enduser)'
|
|
251
|
+
await expectDenied(S2, 'unlisted: form responses', joinSession.authToken, baselineEnduserToken, 'get', '/form-responses')
|
|
252
|
+
await expectDenied(S2, 'unlisted: files', joinSession.authToken, baselineEnduserToken, 'get', '/files')
|
|
253
|
+
await expectDenied(S2, 'unlisted: portal customizations', joinSession.authToken, baselineEnduserToken, 'get', '/portal-customizations')
|
|
254
|
+
await expectDenied(S2, 'unlisted: UNRELATED calendar event', joinSession.authToken, baselineEnduserToken, 'get', `/calendar-event/${unrelatedEvent.id}`)
|
|
255
|
+
|
|
256
|
+
await expectAllowed(S2, 'always-allowed path', joinSession.authToken, 'get', '/test-authenticated')
|
|
257
|
+
await expectAllowed(S2, 'listed: my meetings', joinSession.authToken, 'get', '/my-meetings')
|
|
258
|
+
// enduserActions for organizations is {}, so an enduser 401s here regardless of confinement
|
|
259
|
+
await expectAllowed(S2, 'listed: organizations', joinSession.authToken, 'get', '/organizations', undefined, { allow401: true })
|
|
260
|
+
await expectAllowed(S2, 'listed: users', joinSession.authToken, 'get', '/users')
|
|
261
|
+
await expectAllowed(S2, 'listed: pinned calendar event', joinSession.authToken, 'get', `/calendar-event/${joinEvent.id}`)
|
|
262
|
+
await expectAllowed(S2, 'wildcard: meeting by id', joinSession.authToken, 'get', `/meeting/${OBJECT_ID}`)
|
|
263
|
+
// the two paths widened in this change so the portal video flow keeps working
|
|
264
|
+
await expectAllowed(S2, 'widened: calendar events list', joinSession.authToken, 'get', '/calendar-events')
|
|
265
|
+
await expectAllowed(S2, 'widened: join meeting for event', joinSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: joinEvent.id })
|
|
266
|
+
|
|
267
|
+
// confinement must survive a refresh rather than widening
|
|
268
|
+
const refreshedJoin = await request(joinSession.authToken, 'post', '/refresh-enduser-session', {})
|
|
269
|
+
const refreshedJoinToken = refreshedJoin.body?.authToken
|
|
270
|
+
probes.push({
|
|
271
|
+
scenario: S2, label: 'refresh returned a new token', path: '/refresh-enduser-session',
|
|
272
|
+
expected: 'allowed', status: refreshedJoin.status, pass: !!refreshedJoinToken,
|
|
273
|
+
})
|
|
274
|
+
if (refreshedJoinToken) {
|
|
275
|
+
await expectDenied(S2, 'after refresh: still denied form responses', refreshedJoinToken, baselineEnduserToken, 'get', '/form-responses')
|
|
276
|
+
await expectAllowed(S2, 'after refresh: still allowed my meetings', refreshedJoinToken, 'get', '/my-meetings')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ============ S3: start-link session — STAFF, RED before the fix ============ */
|
|
280
|
+
|
|
281
|
+
const S3 = 'S3 start-link (STAFF)'
|
|
282
|
+
await expectDenied(S3, 'unlisted: form responses', startSession.authToken, baselineUserToken, 'get', '/form-responses')
|
|
283
|
+
await expectDenied(S3, 'unlisted: journeys', startSession.authToken, baselineUserToken, 'get', '/journeys')
|
|
284
|
+
await expectDenied(S3, 'unlisted: tickets', startSession.authToken, baselineUserToken, 'get', '/tickets')
|
|
285
|
+
await expectDenied(S3, 'unlisted: data sync (org-wide change firehose)', startSession.authToken, baselineUserToken, 'get', '/data-sync', { from: new Date(Date.now() - 60_000).toISOString() })
|
|
286
|
+
await expectDenied(S3, 'unlisted: file download URL', startSession.authToken, baselineUserToken, 'get', '/file-download-URL', { secureName: 'f0166-does-not-exist' })
|
|
287
|
+
await expectDenied(S3, 'unlisted: UNRELATED calendar event', startSession.authToken, baselineUserToken, 'get', `/calendar-event/${unrelatedEvent.id}`)
|
|
288
|
+
|
|
289
|
+
await expectAllowed(S3, 'always-allowed path', startSession.authToken, 'get', '/test-authenticated')
|
|
290
|
+
await expectAllowed(S3, 'listed: my meetings', startSession.authToken, 'get', '/my-meetings')
|
|
291
|
+
await expectAllowed(S3, 'listed: calendar events', startSession.authToken, 'get', '/calendar-events')
|
|
292
|
+
await expectAllowed(S3, 'listed: organizations', startSession.authToken, 'get', '/organizations')
|
|
293
|
+
await expectAllowed(S3, 'listed: users', startSession.authToken, 'get', '/users')
|
|
294
|
+
await expectAllowed(S3, 'listed: endusers', startSession.authToken, 'get', '/endusers')
|
|
295
|
+
await expectAllowed(S3, 'listed: pinned calendar event', startSession.authToken, 'get', `/calendar-event/${startEvent.id}`)
|
|
296
|
+
await expectAllowed(S3, 'wildcard: meeting by id', startSession.authToken, 'get', `/meeting/${OBJECT_ID}`)
|
|
297
|
+
await expectAllowed(S3, 'prefix descendant: users bulk-read', startSession.authToken, 'post', '/users/bulk-read', { ids: [hostUser.id] })
|
|
298
|
+
await expectAllowed(S3, 'prefix descendant: endusers bulk-read', startSession.authToken, 'post', '/endusers/bulk-read', { ids: [joinEnduser.id] })
|
|
299
|
+
await expectAllowed(S3, 'widened: join meeting for event', startSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: startEvent.id })
|
|
300
|
+
await expectAllowed(S3, 'widened: add attendees to meeting', startSession.authToken, 'post', '/add-attendees-to-meeting', { id: OBJECT_ID })
|
|
301
|
+
|
|
302
|
+
// login_user propagates allowedPaths implicitly through its ...toSign rest spread — the
|
|
303
|
+
// "(ASSUMPTION)" comments in the mint sites depend on this, so assert it explicitly
|
|
304
|
+
const refreshedStart = await request(startSession.authToken, 'post', '/refresh-session', {})
|
|
305
|
+
const refreshedStartToken = refreshedStart.body?.authToken
|
|
306
|
+
probes.push({
|
|
307
|
+
scenario: S3, label: 'refresh returned a new token', path: '/refresh-session',
|
|
308
|
+
expected: 'allowed', status: refreshedStart.status, pass: !!refreshedStartToken,
|
|
309
|
+
})
|
|
310
|
+
if (refreshedStartToken) {
|
|
311
|
+
await expectDenied(S3, 'after refresh: still denied journeys', refreshedStartToken, baselineUserToken, 'get', '/journeys')
|
|
312
|
+
await expectAllowed(S3, 'after refresh: still allowed my meetings', refreshedStartToken, 'get', '/my-meetings')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ============ S4: side-effects of the widened /calendar-events entry ============ */
|
|
316
|
+
|
|
317
|
+
// Non-wildcard entries still match by bare prefix, so adding '/calendar-events' to the join-link
|
|
318
|
+
// list also prefix-grants '/calendar-events/bulk-update' and friends. Those are staff-only and
|
|
319
|
+
// gated by enduserActions, so an enduser session is still refused — assert that rather than
|
|
320
|
+
// assume it. This is the concrete argument for the segment-boundary follow-up.
|
|
321
|
+
const S4 = 'S4 widening side-effects'
|
|
322
|
+
const prefixGranted: [('patch' | 'post'), string][] = [
|
|
323
|
+
['patch', '/calendar-events/bulk-update'],
|
|
324
|
+
['post', '/calendar-events/report'],
|
|
325
|
+
]
|
|
326
|
+
for (const [method, path] of prefixGranted) {
|
|
327
|
+
const r = await axios.request({
|
|
328
|
+
method,
|
|
329
|
+
url: `${host}/v1${path}`,
|
|
330
|
+
data: {},
|
|
331
|
+
headers: { Authorization: `Bearer ${joinSession.authToken}` },
|
|
332
|
+
validateStatus: () => true,
|
|
333
|
+
})
|
|
334
|
+
probes.push({
|
|
335
|
+
scenario: S4,
|
|
336
|
+
label: `enduser action gating blocks ${method.toUpperCase()} ${path}`,
|
|
337
|
+
path,
|
|
338
|
+
expected: 'denied',
|
|
339
|
+
status: r.status,
|
|
340
|
+
pass: r.status === 401 || r.status === 403,
|
|
341
|
+
note: 'prefix-granted by /calendar-events; blocked by enduserActions instead',
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ============ S6: the namespace grants and the privilege-escalation chain ============ */
|
|
346
|
+
|
|
347
|
+
// Before method-scoped exact matching, '/users' and '/endusers' were collection-namespace
|
|
348
|
+
// grants: 22 and 21 routes each. The worst of them, PATCH /users/consent, called
|
|
349
|
+
// login_user on a fresh DB record and minted a token with NO allowedPaths at all — so an
|
|
350
|
+
// unauthenticated start link could be traded for a fully unconfined staff session.
|
|
351
|
+
const S6 = 'S6 namespace escapes'
|
|
352
|
+
|
|
353
|
+
// expectDenied issues the BASELINE request for real, so mutating probes must be given inert
|
|
354
|
+
// payloads — nonexistent ids or bodies that fail validation. A 400/404 baseline still proves the
|
|
355
|
+
// route is reachable (the check is only `!== 403`). Passing real ids here once made the
|
|
356
|
+
// unconfined baseline actually perform an /endusers/merge, deleting the fixture enduser.
|
|
357
|
+
const ABSENT_ID_A = '68a1b2c3d4e5f60718293a4b'
|
|
358
|
+
const ABSENT_ID_B = '68a1b2c3d4e5f60718293a4c'
|
|
359
|
+
const INVALID_EMAIL = 'f0166-not-an-email'
|
|
360
|
+
|
|
361
|
+
await expectDenied(S6, 'ESCAPE: /users/consent (minted an unconfined token)', startSession.authToken, baselineUserToken, 'patch', '/users/consent', { termsVersion: '1' })
|
|
362
|
+
await expectDenied(S6, 'ESCAPE: /users/switch-account', startSession.authToken, baselineUserToken, 'post', '/users/switch-account', { targetUserId: ABSENT_ID_A })
|
|
363
|
+
await expectDenied(S6, 'ESCAPE: /users/configure-inbox', startSession.authToken, baselineUserToken, 'post', '/users/configure-inbox', {})
|
|
364
|
+
await expectDenied(S6, 'PHI: /endusers/load-inbox-data', startSession.authToken, baselineUserToken, 'get', '/endusers/load-inbox-data')
|
|
365
|
+
await expectDenied(S6, 'PHI: /endusers/report', startSession.authToken, baselineUserToken, 'get', '/endusers/report')
|
|
366
|
+
await expectDenied(S6, 'PHI: /endusers/engagement', startSession.authToken, baselineUserToken, 'get', '/endusers/engagement')
|
|
367
|
+
await expectDenied(S6, 'DESTRUCTIVE: /endusers/merge', startSession.authToken, baselineUserToken, 'post', '/endusers/merge', { sourceEnduserId: ABSENT_ID_A, destinationEnduserId: ABSENT_ID_B })
|
|
368
|
+
await expectDenied(S6, 'DESTRUCTIVE: /endusers/bulk-update', startSession.authToken, baselineUserToken, 'patch', '/endusers/bulk-update', { ids: [ABSENT_ID_A], fields: {} })
|
|
369
|
+
|
|
370
|
+
// method scoping, end to end: the same path is readable but not writable.
|
|
371
|
+
// Baselines use the ADMIN token — the throwaway host user cannot create users or endusers, so it
|
|
372
|
+
// would 403 on permissions and the probe could not distinguish that from a confinement denial.
|
|
373
|
+
await expectAllowed(S6, 'method scoping: GET /endusers allowed', startSession.authToken, 'get', '/endusers')
|
|
374
|
+
await expectDenied(S6, 'method scoping: POST /endusers (create) denied', startSession.authToken, sdk.authToken, 'post', '/endusers', { email: INVALID_EMAIL })
|
|
375
|
+
await expectAllowed(S6, 'method scoping: GET /users allowed', startSession.authToken, 'get', '/users')
|
|
376
|
+
await expectDenied(S6, 'method scoping: POST /users (create) denied', startSession.authToken, sdk.authToken, 'post', '/users', { email: INVALID_EMAIL })
|
|
377
|
+
|
|
378
|
+
// even if consent were reachable, the token it mints must stay confined (defense in depth)
|
|
379
|
+
const consentAttempt = await request(startSession.authToken, 'patch', '/users/consent', { termsVersion: '1' })
|
|
380
|
+
if (consentAttempt.status === 200 && consentAttempt.body?.authToken) {
|
|
381
|
+
await expectDenied(S6, 'consent-minted token is still confined', consentAttempt.body.authToken, baselineUserToken, 'get', '/journeys')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Confinement now lives in a `sessionScopes` claim resolved server-side. Refresh must carry that
|
|
385
|
+
// claim, not silently drop it — login_user's toSign is a deny-list so it propagates, but that is
|
|
386
|
+
// exactly the kind of implicit behavior worth asserting.
|
|
387
|
+
const refreshedForScopes = await request(startSession.authToken, 'post', '/refresh-session', {})
|
|
388
|
+
const rescopedToken = refreshedForScopes.body?.authToken
|
|
389
|
+
probes.push({
|
|
390
|
+
scenario: S6, label: 'refresh returned a new token', path: '/refresh-session',
|
|
391
|
+
expected: 'allowed', status: refreshedForScopes.status, pass: !!rescopedToken,
|
|
392
|
+
})
|
|
393
|
+
if (rescopedToken) {
|
|
394
|
+
await expectDenied(S6, 'after refresh: still cannot reach /users/consent', rescopedToken, baselineUserToken, 'patch', '/users/consent', { termsVersion: '1' })
|
|
395
|
+
await expectDenied(S6, 'after refresh: still cannot reach /endusers/load-inbox-data', rescopedToken, baselineUserToken, 'get', '/endusers/load-inbox-data')
|
|
396
|
+
await expectDenied(S6, 'after refresh: POST /endusers still denied', rescopedToken, baselineUserToken, 'post', '/endusers', { email: `f0166-should-not-exist-${RAND()}@tellescope.example` })
|
|
397
|
+
await expectAllowed(S6, 'after refresh: GET /endusers still allowed', rescopedToken, 'get', '/endusers')
|
|
398
|
+
await expectAllowed(S6, 'after refresh: my-meetings still allowed', rescopedToken, 'get', '/my-meetings')
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* ============ S5: ordinary sessions with no allowedPaths claim ============ */
|
|
402
|
+
|
|
403
|
+
const S5 = 'S5 unconfined sessions'
|
|
404
|
+
for (const path of ['/journeys', '/form-responses', '/endusers', '/calendar-events', '/tickets']) {
|
|
405
|
+
await expectAllowed(S5, `staff session reaches ${path}`, baselineUserToken, 'get', path)
|
|
406
|
+
}
|
|
407
|
+
await expectAllowed(S5, 'staff session reaches /data-sync', baselineUserToken, 'get', '/data-sync', { from: new Date(Date.now() - 60_000).toISOString() })
|
|
408
|
+
for (const path of ['/form-responses', '/calendar-events', '/files']) {
|
|
409
|
+
await expectAllowed(S5, `enduser session reaches ${path}`, baselineEnduserToken, 'get', path)
|
|
410
|
+
}
|
|
411
|
+
const adminAuthenticated = await sdk.test_authenticated()
|
|
412
|
+
probes.push({
|
|
413
|
+
scenario: S5, label: 'admin sdk still authenticated', path: '/test-authenticated',
|
|
414
|
+
expected: 'allowed', status: 200, pass: adminAuthenticated === 'Authenticated!',
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// Headless / server-to-server callers authenticate with an API key, which resolves to a DB user
|
|
418
|
+
// record. `allowedPaths` is a JWT claim only (WithAllowedPaths in types-models is used solely on
|
|
419
|
+
// the session type; there is no schema validator for it), so an API-key session can never carry
|
|
420
|
+
// one and is never path-confined. Assert that rather than reason about it.
|
|
421
|
+
const apiKeySDK = new Session({ host, apiKey: '3n5q0SCBT_iUvZz-b9BJtX7o7HQUVJ9v132PgHJNJsg.' })
|
|
422
|
+
for (const path of ['/journeys', '/form-responses', '/endusers', '/calendar-events', '/files']) {
|
|
423
|
+
const r = await axios.request({
|
|
424
|
+
method: 'get',
|
|
425
|
+
url: `${host}/v1${path}`,
|
|
426
|
+
headers: { Authorization: `API_KEY ${(apiKeySDK as any).apiKey}` },
|
|
427
|
+
validateStatus: () => true,
|
|
428
|
+
})
|
|
429
|
+
probes.push({
|
|
430
|
+
scenario: S5,
|
|
431
|
+
label: `API key (headless) reaches ${path}`,
|
|
432
|
+
path,
|
|
433
|
+
expected: 'allowed',
|
|
434
|
+
status: r.status,
|
|
435
|
+
pass: r.status !== 403,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* ============ report, then assert ============ */
|
|
440
|
+
|
|
441
|
+
// Collect everything before asserting. @tellescope/testing sets EXIT_ON_FIRST_ERROR, so
|
|
442
|
+
// asserting inline would abort at the first failure and hide the rest of the bypass.
|
|
443
|
+
const failures = probes.filter(p => !p.pass)
|
|
444
|
+
|
|
445
|
+
console.log('')
|
|
446
|
+
console.log(' F-0166 — full probe table')
|
|
447
|
+
console.log(' ' + '-'.repeat(112))
|
|
448
|
+
const scenarios = probes.map(p => p.scenario).filter((s, i, all) => all.indexOf(s) === i)
|
|
449
|
+
for (const scenario of scenarios) {
|
|
450
|
+
const inScenario = probes.filter(p => p.scenario === scenario)
|
|
451
|
+
const failed = inScenario.filter(p => !p.pass).length
|
|
452
|
+
console.log(` ${scenario}: ${inScenario.length - failed}/${inScenario.length} passing${failed ? ` <-- ${failed} FAILING` : ''}`)
|
|
453
|
+
for (const p of inScenario) {
|
|
454
|
+
const baseline = p.baselineStatus !== undefined ? ` baseline=${p.baselineStatus}` : ''
|
|
455
|
+
console.log(` ${p.pass ? ' ok ' : 'FAIL'} want=${p.expected.padEnd(7)} got=${p.status}${baseline} ${p.label}${p.note ? ` (${p.note})` : ''}`)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
console.log(' ' + '-'.repeat(112))
|
|
459
|
+
console.log(` TOTAL: ${probes.length - failures.length}/${probes.length} passing, ${failures.length} failing`)
|
|
460
|
+
console.log('')
|
|
461
|
+
|
|
462
|
+
for (const p of probes) {
|
|
463
|
+
assert(
|
|
464
|
+
p.pass,
|
|
465
|
+
`${p.scenario} — ${p.label} (${p.path}): wanted ${p.expected}, got status ${p.status}`
|
|
466
|
+
+ (p.baselineStatus !== undefined ? ` (unconfined baseline got ${p.baselineStatus})` : ''),
|
|
467
|
+
`${p.scenario} — ${p.label}`,
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
} finally {
|
|
471
|
+
for (const id of eventIds) {
|
|
472
|
+
try { await sdk.api.calendar_events.deleteOne(id) } catch {}
|
|
473
|
+
}
|
|
474
|
+
if (joinEnduserId) { try { await sdk.api.endusers.deleteOne(joinEnduserId) } catch {} }
|
|
475
|
+
if (formEnduserEmail) {
|
|
476
|
+
try {
|
|
477
|
+
const e = await sdk.api.endusers.getOne({ email: formEnduserEmail })
|
|
478
|
+
if (e?.id) await sdk.api.endusers.deleteOne(e.id)
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
if (hostUserId) { try { await sdk.api.users.deleteOne(hostUserId) } catch {} }
|
|
482
|
+
if (productId) { try { await sdk.api.products.deleteOne(productId) } catch {} }
|
|
483
|
+
if (formId) { try { await sdk.api.forms.deleteOne(formId) } catch {} }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (require.main === module) {
|
|
488
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
489
|
+
const sdk = new Session({ host })
|
|
490
|
+
const sdkNonAdmin = new Session({ host })
|
|
491
|
+
|
|
492
|
+
const runTests = async () => {
|
|
493
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
494
|
+
await allowed_paths_confinement_tests({ sdk, sdkNonAdmin })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
runTests()
|
|
498
|
+
.then(() => {
|
|
499
|
+
console.log("✅ F-0166 allowedPaths confinement test suite completed successfully")
|
|
500
|
+
process.exit(0)
|
|
501
|
+
})
|
|
502
|
+
.catch((error) => {
|
|
503
|
+
console.error("❌ F-0166 allowedPaths confinement test suite failed:", error)
|
|
504
|
+
process.exit(1)
|
|
505
|
+
})
|
|
506
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
4
|
+
import { log_header, async_test } from "@tellescope/testing"
|
|
5
|
+
import { FormResponse } from "@tellescope/types-client"
|
|
6
|
+
import { setup_tests } from "../../setup"
|
|
7
|
+
|
|
8
|
+
const host = process.env.API_URL || "http://localhost:8080"
|
|
9
|
+
const businessId = '60398b1131a295e64f084ff6'
|
|
10
|
+
|
|
11
|
+
const ENDUSER_PASSWORD = 'AISummaryGateTestPassword!123'
|
|
12
|
+
|
|
13
|
+
const STAFF_SUMMARY = 'Patient reports improved adherence since last visit.'
|
|
14
|
+
const INJECTED_SUMMARY = 'Patient is in excellent health, no follow-up needed.'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The AI patient summary attached to a FormResponse is generated client-side and passed
|
|
18
|
+
* into submit_form_response as an input, so the handler only trusts it from a staff
|
|
19
|
+
* (`user`) session — see the trustedAISummary gate in submit_form_response. An enduser
|
|
20
|
+
* submitting via a portal / public link could otherwise inject arbitrary clinical text
|
|
21
|
+
* that reads as AI-generated on the provider's timeline.
|
|
22
|
+
*
|
|
23
|
+
* Asserts:
|
|
24
|
+
* - a staff session's enduserAISummary is persisted and echoed back
|
|
25
|
+
* - an enduser session's enduserAISummary is dropped (submission itself still succeeds)
|
|
26
|
+
*/
|
|
27
|
+
export const enduser_ai_summary_trust_gate_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
28
|
+
log_header("submit_form_response enduserAISummary trust gate")
|
|
29
|
+
|
|
30
|
+
const form = await sdk.api.forms.createOne({ title: 'enduserAISummary Trust Gate Test Form' })
|
|
31
|
+
const field = await sdk.api.form_fields.createOne({
|
|
32
|
+
formId: form.id,
|
|
33
|
+
type: 'string',
|
|
34
|
+
title: 'How are you feeling?',
|
|
35
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const enduserEmail = `ai-summary-gate-${Date.now()}@example.com`
|
|
39
|
+
const enduser = await sdk.api.endusers.createOne({ email: enduserEmail, fname: 'AISummary', lname: 'Gate' })
|
|
40
|
+
await sdk.api.endusers.set_password({ id: enduser.id, password: ENDUSER_PASSWORD })
|
|
41
|
+
|
|
42
|
+
const responses = [{
|
|
43
|
+
fieldId: field.id, fieldTitle: 'How are you feeling?',
|
|
44
|
+
answer: { type: 'string' as const, value: 'Doing well' },
|
|
45
|
+
}]
|
|
46
|
+
|
|
47
|
+
let staffResponseId: string | undefined
|
|
48
|
+
let enduserResponseId: string | undefined
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// ── staff session: the summary is trusted and persisted ──
|
|
52
|
+
const { accessCode: staffAccessCode } = await sdk.api.form_responses.prepare_form_response({
|
|
53
|
+
enduserId: enduser.id, formId: form.id,
|
|
54
|
+
})
|
|
55
|
+
await async_test(
|
|
56
|
+
"staff session's enduserAISummary is persisted on submit",
|
|
57
|
+
() => sdk.api.form_responses.submit_form_response({
|
|
58
|
+
accessCode: staffAccessCode,
|
|
59
|
+
responses,
|
|
60
|
+
enduserAISummary: STAFF_SUMMARY,
|
|
61
|
+
}),
|
|
62
|
+
{ onResult: ({ formResponse }: { formResponse: FormResponse }) => {
|
|
63
|
+
staffResponseId = formResponse.id
|
|
64
|
+
return !!formResponse.submittedAt && formResponse.enduserAISummary === STAFF_SUMMARY
|
|
65
|
+
}}
|
|
66
|
+
)
|
|
67
|
+
await async_test(
|
|
68
|
+
"staff-submitted enduserAISummary survives a re-fetch",
|
|
69
|
+
() => sdk.api.form_responses.getOne(staffResponseId!),
|
|
70
|
+
{ onResult: (fr: FormResponse) => fr.enduserAISummary === STAFF_SUMMARY },
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// ── enduser session: the summary is untrusted and dropped ──
|
|
74
|
+
const enduserSDK = new EnduserSession({ host, businessId })
|
|
75
|
+
await enduserSDK.authenticate(enduserEmail, ENDUSER_PASSWORD)
|
|
76
|
+
|
|
77
|
+
const { accessCode: enduserAccessCode } = await sdk.api.form_responses.prepare_form_response({
|
|
78
|
+
enduserId: enduser.id, formId: form.id,
|
|
79
|
+
})
|
|
80
|
+
await async_test(
|
|
81
|
+
"enduser session cannot inject enduserAISummary (submission still succeeds)",
|
|
82
|
+
() => enduserSDK.api.form_responses.submit_form_response({
|
|
83
|
+
accessCode: enduserAccessCode,
|
|
84
|
+
responses,
|
|
85
|
+
enduserAISummary: INJECTED_SUMMARY,
|
|
86
|
+
}),
|
|
87
|
+
{ onResult: ({ formResponse }: { formResponse: FormResponse }) => {
|
|
88
|
+
enduserResponseId = formResponse.id
|
|
89
|
+
return !!formResponse.submittedAt && formResponse.enduserAISummary === undefined
|
|
90
|
+
}}
|
|
91
|
+
)
|
|
92
|
+
await async_test(
|
|
93
|
+
"injected enduserAISummary did not persist",
|
|
94
|
+
() => sdk.api.form_responses.getOne(enduserResponseId!),
|
|
95
|
+
{ onResult: (fr: FormResponse) => fr.enduserAISummary === undefined },
|
|
96
|
+
)
|
|
97
|
+
} finally {
|
|
98
|
+
if (staffResponseId) { try { await sdk.api.form_responses.deleteOne(staffResponseId) } catch (err) { /* ignore */ } }
|
|
99
|
+
if (enduserResponseId) { try { await sdk.api.form_responses.deleteOne(enduserResponseId) } catch (err) { /* ignore */ } }
|
|
100
|
+
try { await sdk.api.endusers.deleteOne(enduser.id) } catch (err) { /* ignore */ }
|
|
101
|
+
try { await sdk.api.forms.deleteOne(form.id) } catch (err) { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Allow running this test file independently
|
|
106
|
+
if (require.main === module) {
|
|
107
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
108
|
+
const sdk = new Session({ host })
|
|
109
|
+
const sdkNonAdmin = new Session({ host })
|
|
110
|
+
|
|
111
|
+
const runTests = async () => {
|
|
112
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
113
|
+
await enduser_ai_summary_trust_gate_tests({ sdk, sdkNonAdmin })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
runTests()
|
|
117
|
+
.then(() => {
|
|
118
|
+
console.log("✅ enduserAISummary trust gate tests completed successfully")
|
|
119
|
+
process.exit(0)
|
|
120
|
+
})
|
|
121
|
+
.catch((error) => {
|
|
122
|
+
console.error("❌ enduserAISummary trust gate tests failed:", error)
|
|
123
|
+
process.exit(1)
|
|
124
|
+
})
|
|
125
|
+
}
|