@tellescope/sdk 1.256.13 → 1.256.14
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/tests/api_tests/organization_signup.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
- package/lib/cjs/tests/setup_isolated.d.ts +68 -0
- package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
- package/lib/cjs/tests/setup_isolated.js +232 -0
- package/lib/cjs/tests/setup_isolated.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +257 -209
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
- package/lib/esm/enduser.d.ts +1 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +3 -2
- package/lib/esm/sdk.d.ts.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/organization_signup.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
- package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
- package/lib/esm/tests/setup_isolated.d.ts +68 -0
- package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
- package/lib/esm/tests/setup_isolated.js +221 -0
- package/lib/esm/tests/setup_isolated.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +257 -209
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/organization_signup.test.ts +240 -0
- package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
- package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
- package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
- package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
- package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
- package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
- package/src/tests/setup_isolated.ts +170 -0
- package/src/tests/tests.ts +25 -0
- package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
- package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -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.14",
|
|
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.14",
|
|
36
|
+
"@tellescope/schema": "1.256.14",
|
|
37
|
+
"@tellescope/testing": "1.256.14",
|
|
38
|
+
"@tellescope/types-client": "1.256.14",
|
|
39
|
+
"@tellescope/types-models": "1.256.14",
|
|
40
|
+
"@tellescope/types-utilities": "1.256.14",
|
|
41
|
+
"@tellescope/utilities": "1.256.14",
|
|
42
|
+
"@tellescope/validation": "1.256.14",
|
|
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": "0b32ba0040e286c4a1013287f9e154e612db11af"
|
|
59
59
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
|
|
5
|
+
import { Session } from "../../sdk"
|
|
6
|
+
import { wait } from "@tellescope/testing"
|
|
7
|
+
import {
|
|
8
|
+
assert_checks,
|
|
9
|
+
Check,
|
|
10
|
+
set_email_verification_code,
|
|
11
|
+
setup_isolated_tenant,
|
|
12
|
+
test_support_request,
|
|
13
|
+
} from "../setup_isolated"
|
|
14
|
+
|
|
15
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
16
|
+
|
|
17
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
18
|
+
|
|
19
|
+
// legacy shared fixture — teardown must refuse it
|
|
20
|
+
const SEEDED_TEST_BUSINESS_ID = '60398b1131a295e64f084ff6'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Covers the self-service organization signup chain, which had ZERO test coverage:
|
|
24
|
+
* `users.register` -> `users.submit_email_verification` -> `organizations.create_and_join`.
|
|
25
|
+
*
|
|
26
|
+
* That gap is why F-0191 nearly shipped a schema `adminOnly` flag on create_and_join that would have
|
|
27
|
+
* 401'd every new organization — a freshly registered user has no `roles`, because create_and_join is
|
|
28
|
+
* itself what grants Admin. This test is the regression guard for that.
|
|
29
|
+
*
|
|
30
|
+
* It also covers the new test-support provisioning module, including the assertion that matters most:
|
|
31
|
+
* teardown refuses any organization it did not create.
|
|
32
|
+
*
|
|
33
|
+
* Uses a `dontsend...@tellescope.com` address so the verification email is dropped at
|
|
34
|
+
* forCommunications before it is ever enqueued (locally the worker consumes the same test queue the
|
|
35
|
+
* API writes to, so an unsuppressed registration really does send mail).
|
|
36
|
+
*/
|
|
37
|
+
export const organization_signup_checks = async (): Promise<Check[]> => {
|
|
38
|
+
const checks: Check[] = []
|
|
39
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
40
|
+
|
|
41
|
+
let bootstrapUserId = ''
|
|
42
|
+
let createdBusinessId = ''
|
|
43
|
+
let provisioned: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
/* ===== A: provisioning works with no seeded database ===== */
|
|
47
|
+
|
|
48
|
+
provisioned = await setup_isolated_tenant()
|
|
49
|
+
record(
|
|
50
|
+
'A1: provision-tenant returns a usable admin session',
|
|
51
|
+
!!provisioned.businessId && provisioned.sdk.userInfo?.businessId === provisioned.businessId,
|
|
52
|
+
`businessId=${provisioned.businessId} session businessId=${provisioned.sdk.userInfo?.businessId}`,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// proves the frozen utf8 api-key hash was reproduced correctly, and that the key's creator
|
|
56
|
+
// resolves — a key whose creator is missing fails authentication outright
|
|
57
|
+
let keyDetail = ''
|
|
58
|
+
let keyPass = false
|
|
59
|
+
try {
|
|
60
|
+
const keySession = new Session({ host, apiKey: provisioned.apiKeys[0] })
|
|
61
|
+
keyDetail = await keySession.test_authenticated()
|
|
62
|
+
keyPass = keyDetail === 'Authenticated!'
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
keyDetail = `rejected: ${e?.message || e?.toString()}`
|
|
65
|
+
}
|
|
66
|
+
record('A2: a provisioned API key authenticates', keyPass, `test_authenticated -> ${keyDetail}`)
|
|
67
|
+
|
|
68
|
+
// the tenant is genuinely usable, not just addressable
|
|
69
|
+
const enduser = await provisioned.sdk.api.endusers.createOne({ fname: 'Provisioned', lname: 'Patient' })
|
|
70
|
+
record('A3: the provisioned tenant accepts writes', !!enduser.id, `enduserId=${enduser.id}`)
|
|
71
|
+
|
|
72
|
+
/* ===== B: teardown refuses anything it did not create — the safety assertion ===== */
|
|
73
|
+
|
|
74
|
+
const refusal = await test_support_request('/teardown-tenant', { businessId: SEEDED_TEST_BUSINESS_ID })
|
|
75
|
+
record(
|
|
76
|
+
'B1: teardown REFUSES an organization without the provenance marker',
|
|
77
|
+
refusal.status === 403,
|
|
78
|
+
`status=${refusal.status} ${JSON.stringify(refusal.body)} (expected 403)`,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/* ===== C: the signup chain ===== */
|
|
82
|
+
|
|
83
|
+
const email = `dontsend-signup-${RAND()}@tellescope.com`
|
|
84
|
+
const password = `Signup-${RAND()}-A1!`
|
|
85
|
+
|
|
86
|
+
const registerResult = await axios.request({
|
|
87
|
+
method: 'post', url: `${host}/v1/users/register`,
|
|
88
|
+
data: { email, password, termsVersion: '1' },
|
|
89
|
+
validateStatus: () => true,
|
|
90
|
+
})
|
|
91
|
+
record(
|
|
92
|
+
'C1: register accepts a new org-less user',
|
|
93
|
+
registerResult.status === 204,
|
|
94
|
+
`status=${registerResult.status} ${JSON.stringify(registerResult.data ?? '')}`,
|
|
95
|
+
)
|
|
96
|
+
await wait(undefined, 1200) // register is IP-limited to ~1/sec
|
|
97
|
+
|
|
98
|
+
// login works before verification, which is how a test learns the new user's id
|
|
99
|
+
const preVerifyLogin = await axios.request({
|
|
100
|
+
method: 'post', url: `${host}/v1/users/login`,
|
|
101
|
+
data: { email, password },
|
|
102
|
+
validateStatus: () => true,
|
|
103
|
+
})
|
|
104
|
+
bootstrapUserId = preVerifyLogin.data?.user?.id ?? ''
|
|
105
|
+
record(
|
|
106
|
+
'C2: the unverified user can log in (and is org-less)',
|
|
107
|
+
preVerifyLogin.status === 200 && !!bootstrapUserId && !preVerifyLogin.data?.user?.businessId,
|
|
108
|
+
`status=${preVerifyLogin.status} id=${bootstrapUserId} businessId=${preVerifyLogin.data?.user?.businessId ?? '(none)'}`,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// an unverified session must be rejected by a gated endpoint
|
|
112
|
+
const unverifiedAttempt = await axios.request({
|
|
113
|
+
method: 'post', url: `${host}/v1/organizations/create-and-join`,
|
|
114
|
+
data: { name: 'Should Not Work', subdomain: `nope-${RAND()}` },
|
|
115
|
+
headers: { Authorization: `Bearer ${preVerifyLogin.data?.authToken}` },
|
|
116
|
+
validateStatus: () => true,
|
|
117
|
+
})
|
|
118
|
+
record(
|
|
119
|
+
'C3: create_and_join is rejected before email verification',
|
|
120
|
+
unverifiedAttempt.status === 400,
|
|
121
|
+
`status=${unverifiedAttempt.status} ${JSON.stringify(unverifiedAttempt.data ?? '')}`,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// set the pending code, then drive the REAL verification endpoint
|
|
125
|
+
const code = `verify-${RAND()}`
|
|
126
|
+
await set_email_verification_code(email, code)
|
|
127
|
+
|
|
128
|
+
const verified = await axios.request({
|
|
129
|
+
method: 'post', url: `${host}/v1/users/submit-email-verification`,
|
|
130
|
+
data: { email, code },
|
|
131
|
+
validateStatus: () => true,
|
|
132
|
+
})
|
|
133
|
+
record(
|
|
134
|
+
'C4: submit_email_verification accepts the code and mints a token',
|
|
135
|
+
verified.status === 200 && !!verified.data?.authToken,
|
|
136
|
+
`status=${verified.status} authToken=${!!verified.data?.authToken}`,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
/* ===== D: create_and_join — what F-0191's flag would have broken ===== */
|
|
140
|
+
|
|
141
|
+
const subdomain = `signup-${RAND()}`
|
|
142
|
+
const joined = await axios.request({
|
|
143
|
+
method: 'post', url: `${host}/v1/organizations/create-and-join`,
|
|
144
|
+
data: { name: `Signup Test ${RAND()}`, subdomain },
|
|
145
|
+
headers: { Authorization: `Bearer ${verified.data?.authToken}` },
|
|
146
|
+
validateStatus: () => true,
|
|
147
|
+
})
|
|
148
|
+
createdBusinessId = joined.data?.organization?.id ?? ''
|
|
149
|
+
record(
|
|
150
|
+
'D1: a freshly verified, org-less user can create and join an organization',
|
|
151
|
+
joined.status === 200 && !!createdBusinessId,
|
|
152
|
+
`status=${joined.status} organizationId=${createdBusinessId} ${joined.status !== 200 ? JSON.stringify(joined.data) : ''}`,
|
|
153
|
+
)
|
|
154
|
+
record(
|
|
155
|
+
'D2: the handler grants Admin and sets businessId (the roles bootstrap)',
|
|
156
|
+
joined.data?.user?.roles?.includes('Admin') && joined.data?.user?.businessId === createdBusinessId,
|
|
157
|
+
`roles=${JSON.stringify(joined.data?.user?.roles)} businessId=${joined.data?.user?.businessId}`,
|
|
158
|
+
)
|
|
159
|
+
record(
|
|
160
|
+
'D3: a fresh token is returned',
|
|
161
|
+
!!joined.data?.authToken && joined.data.authToken !== verified.data?.authToken,
|
|
162
|
+
`token returned=${!!joined.data?.authToken}, rotated=${joined.data?.authToken !== verified.data?.authToken}`,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const second = await axios.request({
|
|
166
|
+
method: 'post', url: `${host}/v1/organizations/create-and-join`,
|
|
167
|
+
data: { name: 'Second Org', subdomain: `signup-${RAND()}` },
|
|
168
|
+
headers: { Authorization: `Bearer ${joined.data?.authToken}` },
|
|
169
|
+
validateStatus: () => true,
|
|
170
|
+
})
|
|
171
|
+
record(
|
|
172
|
+
'D4: a user already in an organization cannot create another',
|
|
173
|
+
second.status === 400,
|
|
174
|
+
`status=${second.status} ${JSON.stringify(second.data ?? '')}`,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
/* ===== E: teardown actually removes the tenant ===== */
|
|
178
|
+
|
|
179
|
+
// a second, disposable tenant so the assertions above keep their fixture
|
|
180
|
+
const disposable = await setup_isolated_tenant({ users: [{ isAdmin: true }], apiKeys: 1 })
|
|
181
|
+
const disposableKey = disposable.apiKeys[0]
|
|
182
|
+
await disposable.sdk.api.endusers.createOne({ fname: 'Disposable', lname: 'Patient' })
|
|
183
|
+
await disposable.teardown()
|
|
184
|
+
|
|
185
|
+
const secondTeardown = await test_support_request('/teardown-tenant', { businessId: disposable.businessId })
|
|
186
|
+
record(
|
|
187
|
+
'E1: teardown removes the organization record itself',
|
|
188
|
+
secondTeardown.status === 404,
|
|
189
|
+
`re-teardown status=${secondTeardown.status} ${JSON.stringify(secondTeardown.body)} (expected 404 — org gone)`,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
let revokedDetail = ''
|
|
193
|
+
let revokedPass = false
|
|
194
|
+
try {
|
|
195
|
+
revokedDetail = await (new Session({ host, apiKey: disposableKey })).test_authenticated()
|
|
196
|
+
} catch (e: any) {
|
|
197
|
+
revokedPass = true
|
|
198
|
+
revokedDetail = `rejected: ${e?.message || e?.toString()}`
|
|
199
|
+
}
|
|
200
|
+
record(
|
|
201
|
+
'E2: the torn-down tenant\'s API key no longer authenticates',
|
|
202
|
+
revokedPass,
|
|
203
|
+
revokedDetail,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
} finally {
|
|
207
|
+
if (provisioned) { try { await provisioned.teardown() } catch (e) { console.error('teardown failed', e) } }
|
|
208
|
+
// The signup-created org is NOT test-seeded, so teardown-tenant correctly refuses it; the org row
|
|
209
|
+
// is documented residue. The bootstrap user IS removable via test-support (the
|
|
210
|
+
// /tellescope-admin/delete-user route needs a staff session an isolated test deliberately lacks —
|
|
211
|
+
// posting to it without an Authorization header silently 401'd, so this used to leak every run).
|
|
212
|
+
if (bootstrapUserId) {
|
|
213
|
+
try { await test_support_request('/delete-user', { userId: bootstrapUserId }) } catch {}
|
|
214
|
+
}
|
|
215
|
+
if (createdBusinessId) {
|
|
216
|
+
console.log(` note: organization ${createdBusinessId} was created by the signup flow and is left behind (nothing can delete an org record)`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return checks
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Asserting wrapper, for standalone runs and for sequential use in the suite. */
|
|
224
|
+
export const organization_signup_tests = async () => {
|
|
225
|
+
assert_checks('Organization signup', await organization_signup_checks())
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Allow running this test file independently
|
|
229
|
+
if (require.main === module) {
|
|
230
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
231
|
+
organization_signup_tests()
|
|
232
|
+
.then(() => {
|
|
233
|
+
console.log("✅ Organization signup test suite completed successfully")
|
|
234
|
+
process.exit(0)
|
|
235
|
+
})
|
|
236
|
+
.catch((error) => {
|
|
237
|
+
console.error("❌ Organization signup test suite failed:", error)
|
|
238
|
+
process.exit(1)
|
|
239
|
+
})
|
|
240
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
assert,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { authenticate_enduser_via_token, setup_tests } from "../../setup"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
const businessId = '60398b1131a295e64f084ff6'
|
|
12
|
+
|
|
13
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
14
|
+
|
|
15
|
+
const VICTIM_STATE = 'CA'
|
|
16
|
+
const ATTACKER_STATE_WRITE = 'NY'
|
|
17
|
+
|
|
18
|
+
type Check = {
|
|
19
|
+
label: string,
|
|
20
|
+
pass: boolean,
|
|
21
|
+
detail: string,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Regression test for F-0179
|
|
26
|
+
* (security-audit/findings/F-0179-booking-info-idor-token-mint.md).
|
|
27
|
+
*
|
|
28
|
+
* `form_fields.booking_info` resolved its target patient as
|
|
29
|
+
* `DB.endusers.findById(enduserId || req.session.id)` with no ownership check for enduser
|
|
30
|
+
* sessions. The enduser DB branch builds the endusers collection with `enduserAccess: true`
|
|
31
|
+
* (database.ts), so a FOREIGN in-tenant id resolves — and the handler then writes
|
|
32
|
+
* `enduserFields.state` onto that record and calls `login_enduser` with
|
|
33
|
+
* `sessionScopes: ['appointment-booking']`, returning a live 24h token for the victim inside
|
|
34
|
+
* `bookingURL`. booking_info is enduser-callable AND granted to the 'public-form' scope, so a
|
|
35
|
+
* portal patient or an anonymous form-filler could mint a working session as any co-tenant patient.
|
|
36
|
+
*
|
|
37
|
+
* The fix mirrors `prepare_form_response` (api.ts): reject when an enduser session supplies an
|
|
38
|
+
* `enduserId` other than its own, before the booking-page lookup and before the `state` write.
|
|
39
|
+
*
|
|
40
|
+
* RED (pre-fix): A1 and A2 fail — the exploit returns 200 with a bookingURL whose token decodes to
|
|
41
|
+
* the VICTIM's id, and the victim's `state` is overwritten. B/C (positive controls) pass pre-fix.
|
|
42
|
+
* GREEN (post-fix): all six checks pass.
|
|
43
|
+
*
|
|
44
|
+
* Checks are collected and printed before any assert runs: the testing harness has
|
|
45
|
+
* EXIT_ON_FIRST_ERROR, so asserting inline would kill the process before the evidence table and
|
|
46
|
+
* before `finally` cleanup.
|
|
47
|
+
*/
|
|
48
|
+
export const booking_info_idor_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
49
|
+
log_header("F-0179: booking_info must not mint an appointment-booking token for another enduser")
|
|
50
|
+
|
|
51
|
+
const checks: Check[] = []
|
|
52
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
53
|
+
|
|
54
|
+
// The minted JWT carries `id: enduser._id.toString()` (authentication.ts login_enduser). Decode
|
|
55
|
+
// it straight out of the returned URL — the whole point of the finding is WHOSE session it is.
|
|
56
|
+
const token_enduser_id = (bookingURL: string) => {
|
|
57
|
+
const token = /[?&]token=([^&]+)/.exec(bookingURL || '')?.[1]
|
|
58
|
+
const payload = token?.split('.')[1]
|
|
59
|
+
if (!payload) return ''
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString()).id as string
|
|
62
|
+
} catch {
|
|
63
|
+
return ''
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let templateId = ''
|
|
68
|
+
let bookingPageId = ''
|
|
69
|
+
const enduserIds: string[] = []
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
/* ================= fixtures ================= */
|
|
73
|
+
|
|
74
|
+
const template = await sdk.api.calendar_event_templates.createOne({
|
|
75
|
+
title: `F0179 template ${RAND()}`, durationInMinutes: 30,
|
|
76
|
+
} as any)
|
|
77
|
+
templateId = template.id
|
|
78
|
+
|
|
79
|
+
const bookingPage = await sdk.api.appointment_booking_pages.createOne({
|
|
80
|
+
title: `F0179 booking page ${RAND()}`,
|
|
81
|
+
calendarEventTemplateIds: [templateId],
|
|
82
|
+
locationIds: [],
|
|
83
|
+
} as any)
|
|
84
|
+
bookingPageId = bookingPage.id
|
|
85
|
+
|
|
86
|
+
const attacker = await sdk.api.endusers.createOne({
|
|
87
|
+
email: `f0179-attacker-${RAND()}@tellescope.example`, fname: 'F0179', lname: 'Attacker',
|
|
88
|
+
})
|
|
89
|
+
enduserIds.push(attacker.id)
|
|
90
|
+
|
|
91
|
+
const victim = await sdk.api.endusers.createOne({
|
|
92
|
+
email: `f0179-victim-${RAND()}@tellescope.example`, fname: 'F0179', lname: 'Victim',
|
|
93
|
+
state: VICTIM_STATE,
|
|
94
|
+
})
|
|
95
|
+
enduserIds.push(victim.id)
|
|
96
|
+
|
|
97
|
+
// Token mint, NOT enduserSDK.authenticate — /login-enduser is IP rate-limited and the suite
|
|
98
|
+
// shares one IP.
|
|
99
|
+
const attackerSDK = new EnduserSession({ host, businessId })
|
|
100
|
+
await authenticate_enduser_via_token(sdk, attackerSDK, { id: attacker.id })
|
|
101
|
+
|
|
102
|
+
/* ===== A: the exploit — enduser mints a booking session as another patient ===== */
|
|
103
|
+
|
|
104
|
+
let a1Detail = ''
|
|
105
|
+
let a1Pass = false
|
|
106
|
+
try {
|
|
107
|
+
const r = await attackerSDK.api.form_fields.booking_info({ bookingPageId, enduserId: victim.id })
|
|
108
|
+
const mintedFor = token_enduser_id(r.bookingURL)
|
|
109
|
+
a1Detail = (
|
|
110
|
+
`EXPLOITED: 200 with a bookingURL; token subject=${mintedFor} `
|
|
111
|
+
+ `(victim=${victim.id}, attacker=${attacker.id})`
|
|
112
|
+
+ (mintedFor === victim.id ? ' — a live appointment-booking session AS THE VICTIM' : '')
|
|
113
|
+
)
|
|
114
|
+
} catch (e: any) {
|
|
115
|
+
a1Pass = true
|
|
116
|
+
a1Detail = `rejected: ${e?.message || e?.toString()}`
|
|
117
|
+
}
|
|
118
|
+
record('A1: enduser cannot mint a booking token for another enduser', a1Pass, a1Detail)
|
|
119
|
+
|
|
120
|
+
// A2: the same call also writes enduserFields.state onto the target before minting.
|
|
121
|
+
let a2Attempt = ''
|
|
122
|
+
try {
|
|
123
|
+
await attackerSDK.api.form_fields.booking_info({
|
|
124
|
+
bookingPageId, enduserId: victim.id, enduserFields: { state: ATTACKER_STATE_WRITE },
|
|
125
|
+
})
|
|
126
|
+
a2Attempt = 'call returned 200'
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
a2Attempt = `call rejected: ${e?.message || e?.toString()}`
|
|
129
|
+
}
|
|
130
|
+
const victimAfter = await sdk.api.endusers.getOne(victim.id)
|
|
131
|
+
record(
|
|
132
|
+
'A2: enduser cannot write `state` onto another enduser via enduserFields',
|
|
133
|
+
victimAfter.state === VICTIM_STATE,
|
|
134
|
+
`${a2Attempt}; victim.state=${victimAfter.state} (expected ${VICTIM_STATE})`,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
/* ===== B: positive controls — the patient's own booking flow still works ===== */
|
|
138
|
+
|
|
139
|
+
let b1Detail = ''
|
|
140
|
+
let b1Pass = false
|
|
141
|
+
try {
|
|
142
|
+
const r = await attackerSDK.api.form_fields.booking_info({ bookingPageId })
|
|
143
|
+
const mintedFor = token_enduser_id(r.bookingURL)
|
|
144
|
+
b1Pass = mintedFor === attacker.id
|
|
145
|
+
b1Detail = `token subject=${mintedFor} (self=${attacker.id})`
|
|
146
|
+
} catch (e: any) {
|
|
147
|
+
b1Detail = `unexpectedly rejected: ${e?.message || e?.toString()}`
|
|
148
|
+
}
|
|
149
|
+
record('B1: enduser can still load booking info with no enduserId', b1Pass, b1Detail)
|
|
150
|
+
|
|
151
|
+
let b2Detail = ''
|
|
152
|
+
let b2Pass = false
|
|
153
|
+
try {
|
|
154
|
+
const r = await attackerSDK.api.form_fields.booking_info({ bookingPageId, enduserId: attacker.id })
|
|
155
|
+
const mintedFor = token_enduser_id(r.bookingURL)
|
|
156
|
+
b2Pass = mintedFor === attacker.id
|
|
157
|
+
b2Detail = `token subject=${mintedFor} (self=${attacker.id})`
|
|
158
|
+
} catch (e: any) {
|
|
159
|
+
b2Detail = `unexpectedly rejected: ${e?.message || e?.toString()}`
|
|
160
|
+
}
|
|
161
|
+
record('B2: enduser can still load booking info for its OWN enduserId', b2Pass, b2Detail)
|
|
162
|
+
|
|
163
|
+
/* ===== C: positive controls — the staff branches are untouched ===== */
|
|
164
|
+
|
|
165
|
+
let c1Detail = ''
|
|
166
|
+
let c1Pass = false
|
|
167
|
+
try {
|
|
168
|
+
const r = await sdk.api.form_fields.booking_info({ bookingPageId, enduserId: victim.id })
|
|
169
|
+
const mintedFor = token_enduser_id(r.bookingURL)
|
|
170
|
+
c1Pass = mintedFor === victim.id
|
|
171
|
+
c1Detail = `token subject=${mintedFor} (target=${victim.id})`
|
|
172
|
+
} catch (e: any) {
|
|
173
|
+
c1Detail = `unexpectedly rejected: ${e?.message || e?.toString()}`
|
|
174
|
+
}
|
|
175
|
+
record('C1: staff can still load booking info for a specific enduser', c1Pass, c1Detail)
|
|
176
|
+
|
|
177
|
+
let c2Detail = ''
|
|
178
|
+
let c2Pass = false
|
|
179
|
+
try {
|
|
180
|
+
const r = await sdk.api.form_fields.booking_info({ bookingPageId })
|
|
181
|
+
c2Pass = !!r.bookingURL && !!r.warningMessage
|
|
182
|
+
c2Detail = `warningMessage=${JSON.stringify(r.warningMessage)} bookingURL=${!!r.bookingURL}`
|
|
183
|
+
} catch (e: any) {
|
|
184
|
+
c2Detail = `unexpectedly rejected: ${e?.message || e?.toString()}`
|
|
185
|
+
}
|
|
186
|
+
record('C2: staff test-contact preview branch still works', c2Pass, c2Detail)
|
|
187
|
+
|
|
188
|
+
/* ===== report: print full table first, then assert (harness exits on first failure) ===== */
|
|
189
|
+
console.log('\n F-0179 check summary:')
|
|
190
|
+
for (const c of checks) {
|
|
191
|
+
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
192
|
+
}
|
|
193
|
+
console.log('')
|
|
194
|
+
|
|
195
|
+
for (const c of checks) {
|
|
196
|
+
assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
for (const id of enduserIds) { try { await sdk.api.endusers.deleteOne(id) } catch {} }
|
|
200
|
+
if (bookingPageId) { try { await sdk.api.appointment_booking_pages.deleteOne(bookingPageId) } catch {} }
|
|
201
|
+
if (templateId) { try { await sdk.api.calendar_event_templates.deleteOne(templateId) } catch {} }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Allow running this test file independently
|
|
206
|
+
if (require.main === module) {
|
|
207
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
208
|
+
const sdk = new Session({ host })
|
|
209
|
+
const sdkNonAdmin = new Session({ host })
|
|
210
|
+
|
|
211
|
+
const runTests = async () => {
|
|
212
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
213
|
+
await booking_info_idor_tests({ sdk, sdkNonAdmin })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
runTests()
|
|
217
|
+
.then(() => {
|
|
218
|
+
console.log("✅ F-0179 booking_info IDOR test suite completed successfully")
|
|
219
|
+
process.exit(0)
|
|
220
|
+
})
|
|
221
|
+
.catch((error) => {
|
|
222
|
+
console.error("❌ F-0179 booking_info IDOR test suite failed:", error)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
})
|
|
225
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
4
|
+
import { assert, log_header } from "@tellescope/testing"
|
|
5
|
+
import { setup_tests, authenticate_enduser_via_token } from "../../setup"
|
|
6
|
+
import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
7
|
+
|
|
8
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression test for F-0185 — `calendar_events.startLinkToken` privilege escalation.
|
|
12
|
+
*
|
|
13
|
+
* startLinkToken is a DOCUMENTED feature ("Video Call Start Links"): a tenant's staff / API-key
|
|
14
|
+
* integration PATCHes a CalendarEvent to set a random token, then sends the host user the
|
|
15
|
+
* `/video/startlink?token=...` link, which `session_for_start_link` exchanges for a scope-limited
|
|
16
|
+
* User (staff) authToken for the first `type:'user'` attendee, MFA bypassed. Nothing in the codebase
|
|
17
|
+
* reads the token value back (the redeem matches server-side; the integration holds its own copy), so
|
|
18
|
+
* the field is write-only-by-design.
|
|
19
|
+
*
|
|
20
|
+
* TWO escalation vectors let a patient (or a low-privilege staff user) obtain the host's session:
|
|
21
|
+
* WRITE — `enduserActions.update` + no `enduserUpdatesDisabled` let an enduser attendee PATCH
|
|
22
|
+
* `startLinkToken` to a value they choose, then redeem it.
|
|
23
|
+
* READ — no `redactions` let an enduser attendee (or a non-admin staff user with calendar read)
|
|
24
|
+
* read a LEGITIMATELY staff-set token and redeem it, with no write.
|
|
25
|
+
*
|
|
26
|
+
* Complete fix = TWO flags on schema.ts `startLinkToken`:
|
|
27
|
+
* enduserUpdatesDisabled: true (closes WRITE; preserves the staff/API-key write the feature needs)
|
|
28
|
+
* redactions: ['all'] (closes READ for endusers AND non-admin users; safe — nothing reads
|
|
29
|
+
* the value back). NOT `readonly` (would break the staff write — A2).
|
|
30
|
+
*
|
|
31
|
+
* Checks (collected, then asserted, so a RED run shows every failing vector):
|
|
32
|
+
* A1 enduser attendee PATCH of startLinkToken is REJECTED (WRITE gate)
|
|
33
|
+
* A2 a staff-set token still redeems for the host's session (feature preserved)
|
|
34
|
+
* A3 an enduser-set token (if the write lands) does NOT redeem (defence-in-depth)
|
|
35
|
+
* A4 an enduser attendee does NOT receive startLinkToken on read (READ gate, endusers)
|
|
36
|
+
* A5 a non-admin staff user does NOT receive startLinkToken on read (READ gate, users)
|
|
37
|
+
*
|
|
38
|
+
* No vendor, no Bedrock; the "host" is a throwaway user. Cleans up every created record.
|
|
39
|
+
*/
|
|
40
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
41
|
+
|
|
42
|
+
export const startlinktoken_escalation_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
43
|
+
log_header("F-0185: startLinkToken enduser-writable / readable → staff session escalation")
|
|
44
|
+
|
|
45
|
+
const businessId = sdk.userInfo.businessId
|
|
46
|
+
const userIds: string[] = []
|
|
47
|
+
const enduserIds: string[] = []
|
|
48
|
+
const eventIds: string[] = []
|
|
49
|
+
const roleIds: string[] = []
|
|
50
|
+
|
|
51
|
+
const checks: { label: string, pass: boolean, detail: string }[] = []
|
|
52
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Throwaway host staff user — the account an attacker would escalate INTO.
|
|
56
|
+
const host_user = await sdk.api.users.createOne({
|
|
57
|
+
email: `f0185-host-${RAND()}@tellescope.example`, fname: 'F0185', lname: 'Host',
|
|
58
|
+
verifiedEmail: true, notificationEmailsDisabled: true,
|
|
59
|
+
} as any)
|
|
60
|
+
userIds.push(host_user.id)
|
|
61
|
+
|
|
62
|
+
// The attacking patient, an attendee of the host's appointment.
|
|
63
|
+
const patient = await sdk.api.endusers.createOne({
|
|
64
|
+
email: `f0185-patient-${RAND()}@tellescope.example`, fname: 'F0185', lname: 'Patient',
|
|
65
|
+
})
|
|
66
|
+
enduserIds.push(patient.id)
|
|
67
|
+
|
|
68
|
+
const event = await sdk.api.calendar_events.createOne({
|
|
69
|
+
title: 'F-0185 appointment', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
70
|
+
attendees: [{ type: 'user', id: host_user.id }, { type: 'enduser', id: patient.id }],
|
|
71
|
+
} as any)
|
|
72
|
+
eventIds.push(event.id)
|
|
73
|
+
|
|
74
|
+
const patientSDK = new EnduserSession({ host, businessId })
|
|
75
|
+
await authenticate_enduser_via_token(sdk, patientSDK, { id: patient.id })
|
|
76
|
+
|
|
77
|
+
// ---- A1: WRITE gate — enduser attendee cannot PATCH startLinkToken ----
|
|
78
|
+
const attackerToken = `f0185-attacker-${RAND()}${RAND()}`
|
|
79
|
+
let wrote = false, writeErr = ''
|
|
80
|
+
try {
|
|
81
|
+
await patientSDK.api.calendar_events.updateOne(event.id, { startLinkToken: attackerToken } as any)
|
|
82
|
+
wrote = true
|
|
83
|
+
} catch (err: any) { writeErr = err?.message ?? String(err) }
|
|
84
|
+
record('A1: enduser cannot write startLinkToken', !wrote,
|
|
85
|
+
wrote ? 'PATCH ACCEPTED — patient set a staff start-link token' : `rejected (${writeErr})`)
|
|
86
|
+
|
|
87
|
+
// ---- A3: defence-in-depth — an enduser-set token must not redeem ----
|
|
88
|
+
// Only lands when A1 failed (pre-fix). Read back as admin to see if the value stuck.
|
|
89
|
+
const adminView = await sdk.api.calendar_events.getOne(event.id)
|
|
90
|
+
if ((adminView as any).startLinkToken === attackerToken) {
|
|
91
|
+
let redeemed: any = null
|
|
92
|
+
try { redeemed = await new Session({ host }).api.calendar_events.session_for_start_link({ token: attackerToken }) } catch {}
|
|
93
|
+
record('A3: enduser-set token does not mint a staff session', !redeemed?.authToken,
|
|
94
|
+
redeemed?.authToken ? `minted a STAFF authToken for host ${host_user.id}` : 'not redeemable')
|
|
95
|
+
} else {
|
|
96
|
+
record('A3: enduser-set token does not mint a staff session', true, '(n/a — write blocked, value never landed)')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- A2: feature pin — a STAFF-set token still redeems (write + redeem must both survive the fix) ----
|
|
100
|
+
const staffToken = `f0185-staff-${RAND()}${RAND()}`
|
|
101
|
+
let staffWriteErr = ''
|
|
102
|
+
try { await sdk.api.calendar_events.updateOne(event.id, { startLinkToken: staffToken } as any) }
|
|
103
|
+
catch (e: any) { staffWriteErr = e?.message ?? String(e) }
|
|
104
|
+
let legit: any = null
|
|
105
|
+
try { legit = await new Session({ host }).api.calendar_events.session_for_start_link({ token: staffToken }) }
|
|
106
|
+
catch (e: any) { legit = { error: e?.message ?? String(e) } }
|
|
107
|
+
record('A2: staff-set start link still works', !!legit?.authToken && legit.eventId === event.id,
|
|
108
|
+
`staffWrite=${staffWriteErr ? 'REJECTED(' + staffWriteErr + ')' : 'ok'} authToken=${!!legit?.authToken} eventId=${legit?.eventId} ${legit?.error ? '(' + legit.error + ')' : ''}`)
|
|
109
|
+
|
|
110
|
+
// ---- A4: READ gate (endusers) — patient attendee must not receive the token ----
|
|
111
|
+
let enduserRead: any = null
|
|
112
|
+
try { enduserRead = await patientSDK.api.calendar_events.getOne(event.id) } catch {}
|
|
113
|
+
record('A4: startLinkToken redacted from enduser reads',
|
|
114
|
+
!enduserRead || (enduserRead as any).startLinkToken === undefined,
|
|
115
|
+
`enduser read startLinkToken=${(enduserRead as any)?.startLinkToken ?? '(absent)'} — could redeem a staff-set token`)
|
|
116
|
+
|
|
117
|
+
// ---- A5: READ gate (non-admin users) — low-priv staff must not receive the token ----
|
|
118
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
119
|
+
role: `F0185NonAdmin-${RAND()}`,
|
|
120
|
+
// Non-admin (non-Admin-role) staff with tenant-wide calendar READ — enough to load the event,
|
|
121
|
+
// so the assertion isolates redaction (not access). Spread a valid full permissions shape.
|
|
122
|
+
permissions: { ...PROVIDER_PERMISSIONS, calendar_events: { ...(PROVIDER_PERMISSIONS as any).calendar_events, read: 'All', readMany: 'All' } },
|
|
123
|
+
} as any).catch((e: any) => { console.warn('A5 role create failed:', e?.message ?? e); return null })
|
|
124
|
+
if (rbap) {
|
|
125
|
+
roleIds.push(rbap.id)
|
|
126
|
+
const nonAdmin = await sdk.api.users.createOne({
|
|
127
|
+
email: `f0185-nonadmin-${RAND()}@tellescope.example`, fname: 'F0185', lname: 'NonAdmin',
|
|
128
|
+
verifiedEmail: true, notificationEmailsDisabled: true, roles: [rbap.role],
|
|
129
|
+
} as any)
|
|
130
|
+
userIds.push(nonAdmin.id)
|
|
131
|
+
const { authToken } = await sdk.api.users.generate_auth_token({ id: nonAdmin.id })
|
|
132
|
+
const nonAdminSDK = new Session({ host, authToken })
|
|
133
|
+
let staffRead: any = null
|
|
134
|
+
try { staffRead = await nonAdminSDK.api.calendar_events.getOne(event.id) } catch {}
|
|
135
|
+
record('A5: startLinkToken redacted from non-admin user reads',
|
|
136
|
+
!staffRead || (staffRead as any).startLinkToken === undefined,
|
|
137
|
+
`non-admin user read startLinkToken=${(staffRead as any)?.startLinkToken ?? '(absent)'} — staff→host escalation via read`)
|
|
138
|
+
} else {
|
|
139
|
+
record('A5: startLinkToken redacted from non-admin user reads', true, '(n/a — could not create a non-admin role in this env)')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Report the full table before asserting (the harness exits on the first failed assert).
|
|
143
|
+
console.log('\n F-0185 check summary:')
|
|
144
|
+
for (const c of checks) console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
145
|
+
console.log('')
|
|
146
|
+
for (const c of checks) assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
147
|
+
} finally {
|
|
148
|
+
for (const id of eventIds) { try { await sdk.api.calendar_events.deleteOne(id) } catch {} }
|
|
149
|
+
for (const id of enduserIds) { try { await sdk.api.endusers.deleteOne(id) } catch {} }
|
|
150
|
+
for (const id of userIds) { try { await sdk.api.users.deleteOne(id) } catch {} }
|
|
151
|
+
for (const id of roleIds) { try { await sdk.api.role_based_access_permissions.deleteOne(id) } catch {} }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (require.main === module) {
|
|
156
|
+
const sdk = new Session({ host })
|
|
157
|
+
const sdkNonAdmin = new Session({ host })
|
|
158
|
+
setup_tests(sdk, sdkNonAdmin)
|
|
159
|
+
.then(() => startlinktoken_escalation_tests({ sdk, sdkNonAdmin }))
|
|
160
|
+
.then(() => { console.log("✅ F-0185 startLinkToken escalation test suite completed successfully"); process.exit(0) })
|
|
161
|
+
.catch((error) => { console.error("❌ F-0185 startLinkToken escalation test suite failed:", error); process.exit(1) })
|
|
162
|
+
}
|