@tellescope/sdk 1.256.12 → 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/sdk.d.ts +1 -0
- 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/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 +260 -208
- 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 +4 -2
- 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/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 +260 -208
- 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/sdk.ts +4 -0
- package/src/tests/api_tests/organization_signup.test.ts +240 -0
- package/src/tests/api_tests/out_of_office_endpoint.test.ts +162 -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 +27 -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
|
}
|
package/src/sdk.ts
CHANGED
|
@@ -810,6 +810,9 @@ type Queries = { [K in keyof ClientModelForName]: APIQuery<K> } & {
|
|
|
810
810
|
load_events: (args: extractFields<CustomActions['calendar_events']['load_events']['parameters']>) => (
|
|
811
811
|
Promise<extractFields<CustomActions['calendar_events']['load_events']['returns']>>
|
|
812
812
|
),
|
|
813
|
+
out_of_office: (args?: extractFields<CustomActions['calendar_events']['out_of_office']['parameters']>) => (
|
|
814
|
+
Promise<extractFields<CustomActions['calendar_events']['out_of_office']['returns']>>
|
|
815
|
+
),
|
|
813
816
|
generate_zoom_meeting: (args: extractFields<CustomActions['calendar_events']['generate_zoom_meeting']['parameters']>) => (
|
|
814
817
|
Promise<extractFields<CustomActions['calendar_events']['generate_zoom_meeting']['returns']>>
|
|
815
818
|
),
|
|
@@ -1146,6 +1149,7 @@ export class Session extends SessionManager {
|
|
|
1146
1149
|
queries.calendar_events.session_for_start_link = a => this._GET(`/v1${schema.calendar_events.publicActions.session_for_start_link.path}`, a)
|
|
1147
1150
|
queries.calendar_events.get_events_for_user = a => this._GET(`/v1/${schema.calendar_events.customActions.get_events_for_user.path}`, a)
|
|
1148
1151
|
queries.calendar_events.load_events = a => this._GET(`/v1/${schema.calendar_events.customActions.load_events.path}`, a)
|
|
1152
|
+
queries.calendar_events.out_of_office = a => this._GET(`/v1${schema.calendar_events.customActions.out_of_office.path}`, a)
|
|
1149
1153
|
queries.calendar_events.generate_meeting_link = a => this._POST(`/v1/${schema.calendar_events.customActions.generate_meeting_link.path}`, a)
|
|
1150
1154
|
queries.calendar_events.generate_zoom_meeting = a => this._POST(`/v1/${schema.calendar_events.customActions.generate_zoom_meeting.path}`, a)
|
|
1151
1155
|
queries.calendar_events.change_zoom_host = a => this._POST(`/v1/${schema.calendar_events.customActions.change_zoom_host.path}`, a)
|
|
@@ -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,162 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
assert,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { setup_tests } from "../setup"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
|
|
12
|
+
const MS_PER_MINUTE = 60 * 1000
|
|
13
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE
|
|
14
|
+
|
|
15
|
+
// Covers the calendar_events.out_of_office endpoint, whose whole reason to exist is that it reads
|
|
16
|
+
// ACROSS users: calendar_events is row-filtered on attendees.id and a non-admin defaults to
|
|
17
|
+
// read: 'Assigned', so the ordinary read path returns only the caller's own blocks. The endpoint
|
|
18
|
+
// therefore queries organization-wide on purpose, and returns no event content to make that safe.
|
|
19
|
+
//
|
|
20
|
+
// The cross-user assertion below is the one that matters: swapping the handler's
|
|
21
|
+
// buildOrganizationWideQueries back to the request-scoped DB would silently break peer visibility in
|
|
22
|
+
// Team Chat without breaking a build or any other test.
|
|
23
|
+
export const out_of_office_endpoint_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
24
|
+
log_header("Out of Office Endpoint")
|
|
25
|
+
|
|
26
|
+
const adminId = sdk.userInfo.id
|
|
27
|
+
const nonAdminId = sdkNonAdmin.userInfo.id
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
|
|
30
|
+
// active right now, on the ADMIN, so the non-admin is neither an attendee nor the creator
|
|
31
|
+
const active = await sdk.api.calendar_events.createOne({
|
|
32
|
+
title: 'Out of Office',
|
|
33
|
+
outOfOffice: true,
|
|
34
|
+
startTimeInMS: now - 2 * MS_PER_HOUR,
|
|
35
|
+
durationInMinutes: 8 * 60,
|
|
36
|
+
attendees: [{ id: adminId, type: 'user' }],
|
|
37
|
+
})
|
|
38
|
+
// ended three hours ago — inside the query's start-time range, so only the handler's computed
|
|
39
|
+
// end-time check excludes it
|
|
40
|
+
const ended = await sdk.api.calendar_events.createOne({
|
|
41
|
+
title: 'Out of Office',
|
|
42
|
+
outOfOffice: true,
|
|
43
|
+
startTimeInMS: now - 5 * MS_PER_HOUR,
|
|
44
|
+
durationInMinutes: 2 * 60,
|
|
45
|
+
attendees: [{ id: adminId, type: 'user' }],
|
|
46
|
+
})
|
|
47
|
+
const cancelled = await sdk.api.calendar_events.createOne({
|
|
48
|
+
title: 'Out of Office',
|
|
49
|
+
outOfOffice: true,
|
|
50
|
+
startTimeInMS: now - MS_PER_HOUR,
|
|
51
|
+
durationInMinutes: 4 * 60,
|
|
52
|
+
attendees: [{ id: adminId, type: 'user' }],
|
|
53
|
+
cancelledAt: new Date(),
|
|
54
|
+
})
|
|
55
|
+
// not flagged: a normal event overlapping now must never appear
|
|
56
|
+
const normal = await sdk.api.calendar_events.createOne({
|
|
57
|
+
title: 'Regular Appointment',
|
|
58
|
+
startTimeInMS: now - MS_PER_HOUR,
|
|
59
|
+
durationInMinutes: 4 * 60,
|
|
60
|
+
attendees: [{ id: adminId, type: 'user' }],
|
|
61
|
+
})
|
|
62
|
+
// on the non-admin, used for the userIds scoping check
|
|
63
|
+
const nonAdminOwn = await sdk.api.calendar_events.createOne({
|
|
64
|
+
title: 'Out of Office',
|
|
65
|
+
outOfOffice: true,
|
|
66
|
+
startTimeInMS: now - MS_PER_HOUR,
|
|
67
|
+
durationInMinutes: 4 * 60,
|
|
68
|
+
attendees: [{ id: nonAdminId, type: 'user' }],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const { outOfOffice } = await sdkNonAdmin.api.calendar_events.out_of_office()
|
|
73
|
+
|
|
74
|
+
// assert(assertion, failureMessage, successTitle)
|
|
75
|
+
assert(
|
|
76
|
+
!!outOfOffice.find(o => o.userId === adminId && o.startTimeInMS === active.startTimeInMS),
|
|
77
|
+
'non-admin does NOT see a peer out of office window — is the handler using the request-scoped DB instead of buildOrganizationWideQueries?',
|
|
78
|
+
'non-admin sees a peer out of office window',
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert(
|
|
82
|
+
outOfOffice.every(o => (
|
|
83
|
+
Object.keys(o).sort().join(',') === 'endTimeInMS,startTimeInMS,userId'
|
|
84
|
+
)),
|
|
85
|
+
`response leaked event fields: ${JSON.stringify(outOfOffice[0] ?? {})}`,
|
|
86
|
+
'response carries no event content',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const forAdmin = outOfOffice.filter(o => o.userId === adminId)
|
|
90
|
+
assert(
|
|
91
|
+
forAdmin.length === 1,
|
|
92
|
+
`expected exactly the one active window for the admin, got ${forAdmin.length}: ${JSON.stringify(forAdmin)}`,
|
|
93
|
+
'ended, cancelled and unflagged events are excluded',
|
|
94
|
+
)
|
|
95
|
+
assert(
|
|
96
|
+
forAdmin[0]?.endTimeInMS === active.startTimeInMS + active.durationInMinutes * MS_PER_MINUTE,
|
|
97
|
+
`endTimeInMS was ${forAdmin[0]?.endTimeInMS}, expected ${active.startTimeInMS + active.durationInMinutes * MS_PER_MINUTE}`,
|
|
98
|
+
'endTimeInMS is resolved from startTimeInMS + durationInMinutes',
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// An admin's reads are unrestricted anyway, so this passes even if the handler were reverted to
|
|
102
|
+
// the request-scoped DB — it's the non-admin assertion above that pins the design. Kept as the
|
|
103
|
+
// pair, so the two together show the result is role-independent rather than role-dependent.
|
|
104
|
+
const asAdmin = await sdk.api.calendar_events.out_of_office()
|
|
105
|
+
assert(
|
|
106
|
+
!!asAdmin.outOfOffice.find(o => o.userId === adminId && o.startTimeInMS === active.startTimeInMS)
|
|
107
|
+
&& !!asAdmin.outOfOffice.find(o => o.userId === nonAdminId),
|
|
108
|
+
`admin load missing windows: ${JSON.stringify(asAdmin.outOfOffice)}`,
|
|
109
|
+
'admin sees both its own and a peer out of office window',
|
|
110
|
+
)
|
|
111
|
+
assert(
|
|
112
|
+
asAdmin.outOfOffice.filter(o => o.userId === adminId).length === forAdmin.length,
|
|
113
|
+
`admin and non-admin disagree on the admin's windows: ${JSON.stringify(asAdmin.outOfOffice.filter(o => o.userId === adminId))} vs ${JSON.stringify(forAdmin)}`,
|
|
114
|
+
'admin and non-admin loads return the same windows',
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const scoped = await sdkNonAdmin.api.calendar_events.out_of_office({ userIds: [nonAdminId] })
|
|
118
|
+
assert(
|
|
119
|
+
scoped.outOfOffice.length === 1 && scoped.outOfOffice[0].userId === nonAdminId,
|
|
120
|
+
`expected only the requested user, got ${JSON.stringify(scoped.outOfOffice)}`,
|
|
121
|
+
'userIds scopes the result',
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// a real ObjectId that belongs to no user in this business
|
|
125
|
+
const foreign = await sdkNonAdmin.api.calendar_events.out_of_office({ userIds: ['5fdb3d4a4e6e2a0016a1a1a1'] })
|
|
126
|
+
assert(
|
|
127
|
+
foreign.outOfOffice.length === 0,
|
|
128
|
+
`expected empty for unknown userIds, got ${JSON.stringify(foreign.outOfOffice)}`,
|
|
129
|
+
'unknown userIds return nothing',
|
|
130
|
+
)
|
|
131
|
+
} finally {
|
|
132
|
+
await Promise.all([
|
|
133
|
+
sdk.api.calendar_events.deleteOne(active.id),
|
|
134
|
+
sdk.api.calendar_events.deleteOne(ended.id),
|
|
135
|
+
sdk.api.calendar_events.deleteOne(cancelled.id),
|
|
136
|
+
sdk.api.calendar_events.deleteOne(normal.id),
|
|
137
|
+
sdk.api.calendar_events.deleteOne(nonAdminOwn.id),
|
|
138
|
+
])
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Allow running this test file independently
|
|
143
|
+
if (require.main === module) {
|
|
144
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
145
|
+
const sdk = new Session({ host })
|
|
146
|
+
const sdkNonAdmin = new Session({ host })
|
|
147
|
+
|
|
148
|
+
const runTests = async () => {
|
|
149
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
150
|
+
await out_of_office_endpoint_tests({ sdk, sdkNonAdmin })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
runTests()
|
|
154
|
+
.then(() => {
|
|
155
|
+
console.log("✅ Out of office endpoint test suite completed successfully")
|
|
156
|
+
process.exit(0)
|
|
157
|
+
})
|
|
158
|
+
.catch((error) => {
|
|
159
|
+
console.error("❌ Out of office endpoint test suite failed:", error)
|
|
160
|
+
process.exit(1)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
@@ -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
|
+
}
|