@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.
Files changed (114) hide show
  1. package/lib/cjs/sdk.d.ts +1 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts +20 -0
  6. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
  8. package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  10. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
  12. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
  16. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  18. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  19. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
  20. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  21. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  22. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
  24. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  26. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
  28. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  29. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  30. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  31. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
  32. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  33. package/lib/cjs/tests/setup_isolated.d.ts +68 -0
  34. package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
  35. package/lib/cjs/tests/setup_isolated.js +232 -0
  36. package/lib/cjs/tests/setup_isolated.js.map +1 -0
  37. package/lib/cjs/tests/tests.d.ts.map +1 -1
  38. package/lib/cjs/tests/tests.js +260 -208
  39. package/lib/cjs/tests/tests.js.map +1 -1
  40. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  41. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  42. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
  43. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  44. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  45. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  46. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
  47. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  48. package/lib/esm/enduser.d.ts +1 -0
  49. package/lib/esm/enduser.d.ts.map +1 -1
  50. package/lib/esm/sdk.d.ts +4 -2
  51. package/lib/esm/sdk.d.ts.map +1 -1
  52. package/lib/esm/sdk.js +1 -0
  53. package/lib/esm/sdk.js.map +1 -1
  54. package/lib/esm/session.d.ts +1 -0
  55. package/lib/esm/session.d.ts.map +1 -1
  56. package/lib/esm/tests/api_tests/organization_signup.test.d.ts +20 -0
  57. package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  58. package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
  59. package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
  60. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  61. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  62. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
  63. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  64. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  65. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  66. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
  67. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  68. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  69. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  70. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
  71. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  72. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  73. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  74. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
  75. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  76. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  77. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
  79. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  80. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  81. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  82. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
  83. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  84. package/lib/esm/tests/setup_isolated.d.ts +68 -0
  85. package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
  86. package/lib/esm/tests/setup_isolated.js +221 -0
  87. package/lib/esm/tests/setup_isolated.js.map +1 -0
  88. package/lib/esm/tests/tests.d.ts.map +1 -1
  89. package/lib/esm/tests/tests.js +260 -208
  90. package/lib/esm/tests/tests.js.map +1 -1
  91. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  92. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  93. package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
  94. package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  95. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  96. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  97. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
  98. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  99. package/lib/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +10 -10
  101. package/src/sdk.ts +4 -0
  102. package/src/tests/api_tests/organization_signup.test.ts +240 -0
  103. package/src/tests/api_tests/out_of_office_endpoint.test.ts +162 -0
  104. package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
  105. package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
  106. package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
  107. package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
  108. package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
  109. package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
  110. package/src/tests/setup_isolated.ts +170 -0
  111. package/src/tests/tests.ts +27 -0
  112. package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
  113. package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -0
  114. 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.12",
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.12",
36
- "@tellescope/schema": "1.256.12",
37
- "@tellescope/testing": "1.256.12",
38
- "@tellescope/types-client": "1.256.12",
39
- "@tellescope/types-models": "1.256.12",
40
- "@tellescope/types-utilities": "1.256.12",
41
- "@tellescope/utilities": "1.256.12",
42
- "@tellescope/validation": "1.256.12",
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": "137bfca8fc8d2229a8eee245265536e507a3afae"
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
+ }