@tellescope/sdk 1.250.2 → 1.252.0

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 (136) hide show
  1. package/lib/cjs/sdk.d.ts +9 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +3 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
  7. package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
  9. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
  10. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js +337 -0
  11. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
  12. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
  13. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  14. package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
  15. package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
  16. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
  17. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js +287 -0
  19. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
  20. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  21. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +406 -0
  23. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
  25. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +349 -0
  27. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
  28. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
  29. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
  30. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +247 -0
  31. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
  32. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
  33. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
  34. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +278 -0
  35. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
  36. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
  37. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
  38. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +201 -0
  39. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
  40. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
  41. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
  42. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js +148 -0
  43. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
  44. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
  45. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
  46. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js +88 -0
  47. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
  48. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  49. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  50. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
  51. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  52. package/lib/cjs/tests/setup.d.ts.map +1 -1
  53. package/lib/cjs/tests/setup.js +47 -32
  54. package/lib/cjs/tests/setup.js.map +1 -1
  55. package/lib/cjs/tests/tests.d.ts.map +1 -1
  56. package/lib/cjs/tests/tests.js +215 -159
  57. package/lib/cjs/tests/tests.js.map +1 -1
  58. package/lib/esm/sdk.d.ts +9 -0
  59. package/lib/esm/sdk.d.ts.map +1 -1
  60. package/lib/esm/sdk.js +3 -0
  61. package/lib/esm/sdk.js.map +1 -1
  62. package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  63. package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
  64. package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
  65. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
  66. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
  67. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js +333 -0
  68. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
  69. package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
  70. package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  71. package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
  72. package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
  73. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
  74. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
  75. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
  76. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
  77. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
  78. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
  79. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js +280 -0
  80. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
  81. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  82. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  83. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +402 -0
  84. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  85. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
  86. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
  87. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +345 -0
  88. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
  89. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
  90. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
  91. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +243 -0
  92. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
  93. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
  94. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
  95. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +271 -0
  96. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
  97. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
  98. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
  99. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +194 -0
  100. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
  101. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
  102. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
  103. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js +144 -0
  104. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
  105. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
  106. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
  107. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js +84 -0
  108. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
  109. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  110. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  111. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
  112. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  113. package/lib/esm/tests/setup.d.ts.map +1 -1
  114. package/lib/esm/tests/setup.js +47 -32
  115. package/lib/esm/tests/setup.js.map +1 -1
  116. package/lib/esm/tests/tests.d.ts.map +1 -1
  117. package/lib/esm/tests/tests.js +215 -159
  118. package/lib/esm/tests/tests.js.map +1 -1
  119. package/lib/tsconfig.tsbuildinfo +1 -1
  120. package/package.json +10 -10
  121. package/src/sdk.ts +12 -0
  122. package/src/tests/api_tests/account_switcher.test.ts +1283 -0
  123. package/src/tests/api_tests/calendar_event_webhook_template.test.ts +204 -0
  124. package/src/tests/api_tests/enduser_login.test.ts +215 -0
  125. package/src/tests/api_tests/enduser_login_rate_limits.test.ts +178 -0
  126. package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +223 -0
  127. package/src/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.ts +236 -0
  128. package/src/tests/api_tests/security/F-0005-ai-conversations-rbac.test.ts +154 -0
  129. package/src/tests/api_tests/security/F-0007-invite-user-enumeration.test.ts +198 -0
  130. package/src/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.ts +130 -0
  131. package/src/tests/api_tests/security/F-0013-sanitize-user-html.test.ts +109 -0
  132. package/src/tests/api_tests/security/F-0016-prototype-pollution.test.ts +50 -0
  133. package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
  134. package/src/tests/setup.ts +8 -1
  135. package/src/tests/tests.ts +35 -5
  136. package/test_generated.pdf +0 -0
@@ -0,0 +1,204 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ wait,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ const HEALTHIE_TITLE = 'Healthie' // mirror @tellescope/constants HEALTHIE_TITLE
14
+
15
+ // Unreachable webhook target — the request will fail quickly but the API still writes
16
+ // the resolved payload to webhook_logs, which is what we're verifying.
17
+ const WEBHOOK_TARGET_URL = 'http://127.0.0.1:9'
18
+
19
+ const find_log_for_url = async (sdk: Session, url: string, expectedHealthieId: string) => {
20
+ const start = Date.now()
21
+ while (Date.now() - start < 10_000) {
22
+ const logs = await sdk.api.webhook_logs.getSome({ limit: 50, sort: 'newFirst' as any }) as Array<any>
23
+ const match = logs.find(l => l.url === url && (l.payload as any)?.healthieId === expectedHealthieId)
24
+ if (match) return match
25
+ await wait(undefined, 500)
26
+ }
27
+ return null
28
+ }
29
+
30
+ // Main test function that can be called independently
31
+ export const calendar_event_webhook_template_tests = async (
32
+ { sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }
33
+ ) => {
34
+ log_header("Calendar Event Webhook Template Tests")
35
+
36
+ const enduser = await sdk.api.endusers.createOne({})
37
+
38
+ // Case 1: Healthie ID resolved via source + externalId
39
+ const externalIdEvent = await sdk.api.calendar_events.createOne({
40
+ title: 'External ID Calendar Event',
41
+ durationInMinutes: 30,
42
+ startTimeInMS: Date.now(),
43
+ attendees: [{ type: 'enduser', id: enduser.id }],
44
+ source: HEALTHIE_TITLE,
45
+ externalId: 'evt_HEALTHIE_123',
46
+ } as any)
47
+
48
+ // Case 2: Healthie ID resolved via references
49
+ const referencesEvent = await sdk.api.calendar_events.createOne({
50
+ title: 'References Calendar Event',
51
+ durationInMinutes: 30,
52
+ startTimeInMS: Date.now(),
53
+ attendees: [{ type: 'enduser', id: enduser.id }],
54
+ references: [{ type: HEALTHIE_TITLE, id: 'evt_REF_456' }],
55
+ } as any)
56
+
57
+ // create an automation step to satisfy schema validation (automationStepId is required)
58
+ const journey = await sdk.api.journeys.createOne({
59
+ title: 'Calendar Event Webhook Template Tests',
60
+ defaultState: 'State 1',
61
+ })
62
+ const step = await sdk.api.automation_steps.createOne({
63
+ journeyId: journey.id,
64
+ events: [{ type: 'onJourneyStart', info: {} }],
65
+ action: {
66
+ type: 'sendWebhook',
67
+ info: {
68
+ message: 'placeholder',
69
+ url: WEBHOOK_TARGET_URL,
70
+ },
71
+ },
72
+ })
73
+
74
+ try {
75
+ await async_test(
76
+ 'Send Webhook resolves {{calendar_event.Healthie ID}} from source+externalId',
77
+ async () => {
78
+ const url = `${WEBHOOK_TARGET_URL}/external/${externalIdEvent.id}`
79
+ await sdk.api.webhooks.send_automation_webhook({
80
+ message: 'placeholder',
81
+ enduserId: enduser.id,
82
+ automationStepId: step.id,
83
+ action: {
84
+ type: 'sendWebhook',
85
+ info: {
86
+ message: 'placeholder',
87
+ url,
88
+ fields: [
89
+ { field: 'healthieId', value: '{{calendar_event.Healthie ID}}' },
90
+ { field: 'title', value: '{{calendar_event.title}}' },
91
+ ],
92
+ },
93
+ },
94
+ context: { calendarEventId: externalIdEvent.id },
95
+ }).catch(() => {}) // the unreachable URL is expected to error after logging
96
+
97
+ const log = await find_log_for_url(sdk, url, 'evt_HEALTHIE_123')
98
+ if (!log) return false
99
+ const payload = log.payload as any
100
+ return payload?.healthieId === 'evt_HEALTHIE_123'
101
+ && payload?.title === externalIdEvent.title
102
+ },
103
+ { onResult: r => r === true }
104
+ )
105
+
106
+ await async_test(
107
+ 'Send Webhook resolves {{calendar_event.Healthie ID}} from references',
108
+ async () => {
109
+ const url = `${WEBHOOK_TARGET_URL}/refs/${referencesEvent.id}`
110
+ await sdk.api.webhooks.send_automation_webhook({
111
+ message: 'placeholder',
112
+ enduserId: enduser.id,
113
+ automationStepId: step.id,
114
+ action: {
115
+ type: 'sendWebhook',
116
+ info: {
117
+ message: 'placeholder',
118
+ url,
119
+ fields: [
120
+ { field: 'healthieId', value: '{{calendar_event.Healthie ID}}' },
121
+ { field: 'title', value: '{{calendar_event.title}}' },
122
+ ],
123
+ },
124
+ },
125
+ context: { calendarEventId: referencesEvent.id },
126
+ }).catch(() => {})
127
+
128
+ const log = await find_log_for_url(sdk, url, 'evt_REF_456')
129
+ if (!log) return false
130
+ const payload = log.payload as any
131
+ return payload?.healthieId === 'evt_REF_456'
132
+ && payload?.title === referencesEvent.title
133
+ },
134
+ { onResult: r => r === true }
135
+ )
136
+
137
+ await async_test(
138
+ 'Send Webhook leaves {{calendar_event.Healthie ID}} blank when no calendarEventId in context',
139
+ async () => {
140
+ const url = `${WEBHOOK_TARGET_URL}/nocontext`
141
+ await sdk.api.webhooks.send_automation_webhook({
142
+ message: 'placeholder',
143
+ enduserId: enduser.id,
144
+ automationStepId: step.id,
145
+ action: {
146
+ type: 'sendWebhook',
147
+ info: {
148
+ message: 'placeholder',
149
+ url,
150
+ fields: [
151
+ { field: 'healthieId', value: '{{calendar_event.Healthie ID}}' },
152
+ ],
153
+ },
154
+ },
155
+ // no calendarEventId
156
+ }).catch(() => {})
157
+
158
+ // Without a calendar event in context, the templating helper returns the input
159
+ // unchanged, so the literal template string remains in the payload.
160
+ const start = Date.now()
161
+ while (Date.now() - start < 10_000) {
162
+ const logs = await sdk.api.webhook_logs.getSome({ limit: 50, sort: 'newFirst' as any }) as Array<any>
163
+ const match = logs.find(l => l.url === url)
164
+ if (match) {
165
+ const payload = match.payload as any
166
+ return payload?.healthieId === '{{calendar_event.Healthie ID}}'
167
+ }
168
+ await wait(undefined, 500)
169
+ }
170
+ return false
171
+ },
172
+ { onResult: r => r === true }
173
+ )
174
+
175
+ } finally {
176
+ try { await sdk.api.calendar_events.deleteOne(externalIdEvent.id) } catch {}
177
+ try { await sdk.api.calendar_events.deleteOne(referencesEvent.id) } catch {}
178
+ try { await sdk.api.automation_steps.deleteOne(step.id) } catch {}
179
+ try { await sdk.api.journeys.deleteOne(journey.id) } catch {}
180
+ try { await sdk.api.endusers.deleteOne(enduser.id) } catch {}
181
+ }
182
+ }
183
+
184
+ // Allow running this test file independently
185
+ if (require.main === module) {
186
+ console.log(`🌐 Using API URL: ${host}`)
187
+ const sdk = new Session({ host })
188
+ const sdkNonAdmin = new Session({ host })
189
+
190
+ const runTests = async () => {
191
+ await setup_tests(sdk, sdkNonAdmin)
192
+ await calendar_event_webhook_template_tests({ sdk, sdkNonAdmin })
193
+ }
194
+
195
+ runTests()
196
+ .then(() => {
197
+ console.log("✅ Calendar event webhook template test suite completed successfully")
198
+ process.exit(0)
199
+ })
200
+ .catch((error) => {
201
+ console.error("❌ Calendar event webhook template test suite failed:", error)
202
+ process.exit(1)
203
+ })
204
+ }
@@ -0,0 +1,215 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ // Coverage for the /v1/login-enduser response surface:
14
+ // - When an enduser has no password set, the error response must not include
15
+ // enduser fields (PHI, hashedPassword, etc.).
16
+ // - The "enduser not found" and "wrong password" cases must be indistinguishable
17
+ // (same status code, same message) to prevent account enumeration.
18
+ // - verify_otp invalid-code error must not include enduser fields.
19
+
20
+ const post_login = async (body: any) => {
21
+ try {
22
+ const res = await axios.post(`${host}/v1/login-enduser`, body, { validateStatus: () => true })
23
+ return { status: res.status, data: res.data }
24
+ } catch (err: any) {
25
+ return { status: err?.response?.status, data: err?.response?.data }
26
+ }
27
+ }
28
+
29
+ export const enduser_login_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
30
+ log_header("Enduser Login Tests")
31
+
32
+ const ts = Date.now()
33
+ // Distinctive markers we can grep the response body for.
34
+ const MARKER_FNAME = `LoginTestFname${ts}`
35
+ const MARKER_ADDRESS = `${ts} Test Way`
36
+ const MARKER_DOB = '01-01-1990'
37
+
38
+ const noPasswordEnduser = await sdk.api.endusers.createOne({
39
+ fname: MARKER_FNAME,
40
+ lname: 'LoginTest',
41
+ email: `login-test-no-password-${ts}@tellescope.com`,
42
+ dateOfBirth: MARKER_DOB,
43
+ addressLineOne: MARKER_ADDRESS,
44
+ addressLineTwo: 'Apt 4B',
45
+ city: 'Springfield',
46
+ state: 'IL',
47
+ zipCode: '62701',
48
+ gender: 'Female',
49
+ assignedTo: [sdk.userInfo.id],
50
+ fields: { secretField: `should-not-leak-${ts}` },
51
+ tags: ['vip', 'sensitive'],
52
+ })
53
+
54
+ const withPasswordEnduser = await sdk.api.endusers.createOne({
55
+ fname: 'PasswordedEnduser',
56
+ lname: 'LoginTest',
57
+ email: `login-test-with-password-${ts}@tellescope.com`,
58
+ })
59
+ await sdk.api.endusers.set_password({ id: withPasswordEnduser.id, password: 'CorrectPassword123!' })
60
+
61
+ try {
62
+ // Login response for an enduser without a password set must not include
63
+ // enduser fields.
64
+ const noPasswordResp = await post_login({
65
+ email: noPasswordEnduser.email,
66
+ password: 'arbitrary-password',
67
+ businessId: sdk.userInfo.businessId,
68
+ })
69
+
70
+ const noPasswordBody = JSON.stringify(noPasswordResp.data ?? {})
71
+
72
+ await async_test(
73
+ 'No-password login response does not include fname marker',
74
+ async () => noPasswordBody.includes(MARKER_FNAME) ? 'leaked' : 'safe',
75
+ { expectedResult: 'safe' }
76
+ )
77
+ await async_test(
78
+ 'No-password login response does not include dateOfBirth',
79
+ async () => noPasswordBody.includes(MARKER_DOB) ? 'leaked' : 'safe',
80
+ { expectedResult: 'safe' }
81
+ )
82
+ await async_test(
83
+ 'No-password login response does not include addressLineOne marker',
84
+ async () => noPasswordBody.includes(MARKER_ADDRESS) ? 'leaked' : 'safe',
85
+ { expectedResult: 'safe' }
86
+ )
87
+ for (const sensitiveKey of ['hashedPassword', 'assignedTo', 'fields', 'tags', 'insurance', 'customFields']) {
88
+ await async_test(
89
+ `No-password login response does not include "${sensitiveKey}" key`,
90
+ async () => noPasswordBody.includes(`"${sensitiveKey}"`) ? 'leaked' : 'safe',
91
+ { expectedResult: 'safe' }
92
+ )
93
+ }
94
+ await async_test(
95
+ 'No-password login response info field is absent or empty',
96
+ async () => {
97
+ const info = (noPasswordResp.data ?? {}).info
98
+ if (info === undefined) return 'safe'
99
+ if (typeof info === 'object' && info !== null && Object.keys(info).length === 0) return 'safe'
100
+ return 'leaked'
101
+ },
102
+ { expectedResult: 'safe' }
103
+ )
104
+
105
+ // All three failure modes (no-password-set, wrong-password, unknown-email)
106
+ // must return an identical 401 + identical message — no enumeration of
107
+ // which case actually applies.
108
+ const wrongPasswordResp = await post_login({
109
+ email: withPasswordEnduser.email,
110
+ password: 'WrongPassword!2025',
111
+ businessId: sdk.userInfo.businessId,
112
+ })
113
+ const unknownEmailResp = await post_login({
114
+ email: `does-not-exist-${ts}@tellescope.com`,
115
+ password: 'AnyPassword!2025',
116
+ businessId: sdk.userInfo.businessId,
117
+ })
118
+
119
+ await async_test(
120
+ 'Login returns 401 for no-password account (indistinguishable from wrong password)',
121
+ async () => `${noPasswordResp.status}:${noPasswordResp.data?.message ?? ''}`,
122
+ { expectedResult: '401:Login details are invalid' }
123
+ )
124
+ await async_test(
125
+ 'Login returns same status for no-password vs wrong-password vs unknown-email',
126
+ async () => {
127
+ const statuses = [noPasswordResp.status, wrongPasswordResp.status, unknownEmailResp.status]
128
+ const allSame = statuses.every(s => s === statuses[0])
129
+ return allSame ? `same:${statuses[0]}` : `diff:${statuses.join(',')}`
130
+ },
131
+ { expectedResult: 'same:401' }
132
+ )
133
+ await async_test(
134
+ 'Login returns same message for no-password vs wrong-password vs unknown-email',
135
+ async () => {
136
+ const messages = [noPasswordResp.data?.message, wrongPasswordResp.data?.message, unknownEmailResp.data?.message]
137
+ const allSame = messages.every(m => m === messages[0])
138
+ return allSame ? 'same' : `diff:${JSON.stringify(messages)}`
139
+ },
140
+ { expectedResult: 'same' }
141
+ )
142
+
143
+ // verify_otp invalid-code response must not include enduser fields.
144
+ const verifyOtpInvalidResp = await axios.post(
145
+ `${host}/v1/verify-otp-code`,
146
+ { token: 'not-a-real-token', code: '000000', businessId: sdk.userInfo.businessId },
147
+ { validateStatus: () => true }
148
+ )
149
+ const verifyOtpBody = JSON.stringify(verifyOtpInvalidResp.data ?? {})
150
+ await async_test(
151
+ 'verify_otp invalid-code response does not include any enduser fields',
152
+ async () => (
153
+ verifyOtpBody.includes('"hashedPassword"')
154
+ || verifyOtpBody.includes('"assignedTo"')
155
+ || verifyOtpBody.includes(MARKER_FNAME)
156
+ ? 'leaked' : 'safe'
157
+ ),
158
+ { expectedResult: 'safe' }
159
+ )
160
+
161
+ // Regression: admin creating a duplicate enduser (same email) must still
162
+ // receive the existing record's id in the error info — this is an
163
+ // authenticated, intentional API-aid pattern via uniquenessError and must
164
+ // not be regressed by the public-endpoint sanitizer.
165
+ let duplicateError: any = null
166
+ try {
167
+ await sdk.api.endusers.createOne({ email: noPasswordEnduser.email })
168
+ } catch (err: any) {
169
+ duplicateError = err
170
+ }
171
+ await async_test(
172
+ 'Admin duplicate enduser create rejects with 409 Uniqueness Violation',
173
+ async () => duplicateError?.message ?? 'no-error',
174
+ { expectedResult: 'Uniqueness Violation' }
175
+ )
176
+ await async_test(
177
+ 'Admin duplicate enduser create returns the existing record id in info',
178
+ async () => {
179
+ const info = duplicateError?.info
180
+ if (!Array.isArray(info) || info.length === 0) return `no-info:${JSON.stringify(info)}`
181
+ const existing = info[0]?.existingRecord
182
+ const existingId = existing?._id ?? existing?.id
183
+ return existingId === noPasswordEnduser.id ? 'matched' : `mismatched:${existingId}`
184
+ },
185
+ { expectedResult: 'matched' }
186
+ )
187
+ } finally {
188
+ await Promise.all([
189
+ sdk.api.endusers.deleteOne(noPasswordEnduser.id).catch(() => null),
190
+ sdk.api.endusers.deleteOne(withPasswordEnduser.id).catch(() => null),
191
+ ])
192
+ }
193
+ }
194
+
195
+ // Allow running this test file independently
196
+ if (require.main === module) {
197
+ console.log(`🌐 Using API URL: ${host}`)
198
+ const sdk = new Session({ host })
199
+ const sdkNonAdmin = new Session({ host })
200
+
201
+ const runTests = async () => {
202
+ await setup_tests(sdk, sdkNonAdmin)
203
+ await enduser_login_tests({ sdk, sdkNonAdmin })
204
+ }
205
+
206
+ runTests()
207
+ .then(() => {
208
+ console.log("✅ Enduser login test suite completed successfully")
209
+ process.exit(0)
210
+ })
211
+ .catch((error) => {
212
+ console.error("❌ Enduser login test suite failed:", error)
213
+ process.exit(1)
214
+ })
215
+ }
@@ -0,0 +1,178 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ // Per-IP rate limits applied to enduser public endpoints:
14
+ // POST /v1/login-enduser 20 / min, 100 / hour
15
+ // POST /v1/begin-enduser-login-flow 10 / min, 50 / hour
16
+ // POST /v1/endusers/send-otp-code 5 / min, 30 / hour
17
+ // Plus a per-identifier limit on begin_login_flow: 5 / 10 min per email|phone.
18
+
19
+ const post = async (path: string, body: any) => {
20
+ try {
21
+ const res = await axios.post(`${host}${path}`, body, { validateStatus: () => true })
22
+ return { status: res.status, data: res.data }
23
+ } catch (err: any) {
24
+ return { status: err?.response?.status, data: err?.response?.data }
25
+ }
26
+ }
27
+
28
+ const fire_until_429 = async (cap: number, send: (i: number) => Promise<{ status: number }>) => {
29
+ let triggeredAt = -1
30
+ for (let i = 0; i < cap + 5; i++) {
31
+ const { status } = await send(i)
32
+ if (status === 429) {
33
+ triggeredAt = i
34
+ break
35
+ }
36
+ }
37
+ return triggeredAt
38
+ }
39
+
40
+ export const enduser_login_rate_limits_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
41
+ log_header("Enduser Login Rate Limit Tests")
42
+
43
+ const businessId = sdk.userInfo.businessId
44
+
45
+ // Ensure throttled_events from prior tests don't bleed in.
46
+ await sdk.reset_db()
47
+
48
+ // ---- /v1/login-enduser per-IP cap (20/min) ----
49
+ // Bogus emails ensure we never reach the actual DB lookup / login work.
50
+ const loginTrip = await fire_until_429(20, i => post('/v1/login-enduser', {
51
+ email: `rl-login-${Date.now()}-${i}@tellescope.com`,
52
+ password: 'NotARealPassword!2025',
53
+ businessId,
54
+ }))
55
+ await async_test(
56
+ 'login per-IP throttle trips within first 21 requests',
57
+ async () => (loginTrip >= 0 && loginTrip <= 20) ? 'tripped' : `not-tripped:${loginTrip}`,
58
+ { expectedResult: 'tripped' }
59
+ )
60
+
61
+ await sdk.reset_db()
62
+
63
+ // ---- /v1/begin-enduser-login-flow per-IP cap (10/min) ----
64
+ // Use distinct emails so the per-identifier limit (5/10min) does NOT trip first;
65
+ // we want the IP cap to be the first thing to fire.
66
+ const beginIpTrip = await fire_until_429(10, i => post('/v1/begin-enduser-login-flow', {
67
+ email: `rl-begin-ip-${Date.now()}-${i}@tellescope.com`,
68
+ businessId,
69
+ }))
70
+ await async_test(
71
+ 'begin_login_flow per-IP throttle trips within first 11 requests',
72
+ async () => (beginIpTrip >= 0 && beginIpTrip <= 10) ? 'tripped' : `not-tripped:${beginIpTrip}`,
73
+ { expectedResult: 'tripped' }
74
+ )
75
+
76
+ await sdk.reset_db()
77
+
78
+ // ---- /v1/begin-enduser-login-flow per-identifier cap (5 / 10 min per email) ----
79
+ // Hit a single email below the per-IP cap.
80
+ const fixedEmail = `rl-begin-id-${Date.now()}@tellescope.com`
81
+ const beginIdTrip = await fire_until_429(5, () => post('/v1/begin-enduser-login-flow', {
82
+ email: fixedEmail,
83
+ businessId,
84
+ }))
85
+ await async_test(
86
+ 'begin_login_flow per-identifier throttle trips within first 6 requests',
87
+ async () => (beginIdTrip >= 0 && beginIdTrip <= 5) ? 'tripped' : `not-tripped:${beginIdTrip}`,
88
+ { expectedResult: 'tripped' }
89
+ )
90
+
91
+ await sdk.reset_db()
92
+
93
+ // ---- /v1/endusers/send-otp-code per-IP cap (5/min) ----
94
+ // Use a bogus JWT-shaped token so we trip the IP guard first (it runs before any DB work).
95
+ const fakeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9.sig'
96
+ const sendOtpTrip = await fire_until_429(5, () => post('/v1/endusers/send-otp-code', {
97
+ token: fakeToken,
98
+ method: 'email',
99
+ }))
100
+ await async_test(
101
+ 'send_otp per-IP throttle trips within first 6 requests',
102
+ async () => (sendOtpTrip >= 0 && sendOtpTrip <= 5) ? 'tripped' : `not-tripped:${sendOtpTrip}`,
103
+ { expectedResult: 'tripped' }
104
+ )
105
+
106
+ // Confirm 429 response does not leak the keying strategy (no mention of "ip" or "address").
107
+ const tripped = await post('/v1/endusers/send-otp-code', { token: fakeToken, method: 'email' })
108
+ await async_test(
109
+ '429 response does not mention "ip" or "address"',
110
+ async () => {
111
+ const msg = (tripped.data?.message ?? '').toLowerCase()
112
+ return (msg.includes('ip') || msg.includes('address')) ? 'leaked' : 'safe'
113
+ },
114
+ { expectedResult: 'safe' }
115
+ )
116
+
117
+ // ---- Legitimate-login regression: a single successful login should still go through ----
118
+ // Reset state, then create a real enduser with a password and confirm one login succeeds.
119
+ await sdk.reset_db()
120
+
121
+ const ts = Date.now()
122
+ const enduser = await sdk.api.endusers.createOne({
123
+ fname: 'RateLimitOk', lname: 'Enduser',
124
+ email: `rl-legit-${ts}@tellescope.com`,
125
+ })
126
+ try {
127
+ await sdk.api.endusers.set_password({ id: enduser.id, password: 'CorrectPassword123!' })
128
+
129
+ const goodLogin = await post('/v1/login-enduser', {
130
+ email: enduser.email,
131
+ password: 'CorrectPassword123!',
132
+ businessId,
133
+ })
134
+ await async_test(
135
+ 'legitimate login still succeeds (returns authToken, not 429)',
136
+ async () => goodLogin.status === 200 && !!goodLogin.data?.authToken ? 'ok' : `failed:${goodLogin.status}`,
137
+ { expectedResult: 'ok' }
138
+ )
139
+
140
+ // A subsequent successful login by the same user/IP should also succeed —
141
+ // a single legitimate user retrying must not trip the per-IP cap.
142
+ const goodLoginRetry = await post('/v1/login-enduser', {
143
+ email: enduser.email,
144
+ password: 'CorrectPassword123!',
145
+ businessId,
146
+ })
147
+ await async_test(
148
+ 'legitimate login retry still succeeds',
149
+ async () => goodLoginRetry.status === 200 && !!goodLoginRetry.data?.authToken ? 'ok' : `failed:${goodLoginRetry.status}`,
150
+ { expectedResult: 'ok' }
151
+ )
152
+ } finally {
153
+ await sdk.api.endusers.deleteOne(enduser.id).catch(() => null)
154
+ await sdk.reset_db().catch(() => null)
155
+ }
156
+ }
157
+
158
+ // Allow running this test file independently
159
+ if (require.main === module) {
160
+ console.log(`🌐 Using API URL: ${host}`)
161
+ const sdk = new Session({ host })
162
+ const sdkNonAdmin = new Session({ host })
163
+
164
+ const runTests = async () => {
165
+ await setup_tests(sdk, sdkNonAdmin)
166
+ await enduser_login_rate_limits_tests({ sdk, sdkNonAdmin })
167
+ }
168
+
169
+ runTests()
170
+ .then(() => {
171
+ console.log("✅ Enduser login rate limit test suite completed successfully")
172
+ process.exit(0)
173
+ })
174
+ .catch((error) => {
175
+ console.error("❌ Enduser login rate limit test suite failed:", error)
176
+ process.exit(1)
177
+ })
178
+ }