@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.
- package/lib/cjs/sdk.d.ts +9 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +3 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
- package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js +337 -0
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js +287 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +406 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +349 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +247 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +278 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +201 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js +148 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js +88 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +47 -32
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +215 -159
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +9 -0
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +3 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
- package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js +333 -0
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js +280 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +402 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +345 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +243 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +271 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +194 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js +144 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js +84 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +47 -32
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +215 -159
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +12 -0
- package/src/tests/api_tests/account_switcher.test.ts +1283 -0
- package/src/tests/api_tests/calendar_event_webhook_template.test.ts +204 -0
- package/src/tests/api_tests/enduser_login.test.ts +215 -0
- package/src/tests/api_tests/enduser_login_rate_limits.test.ts +178 -0
- package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +223 -0
- package/src/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.ts +236 -0
- package/src/tests/api_tests/security/F-0005-ai-conversations-rbac.test.ts +154 -0
- package/src/tests/api_tests/security/F-0007-invite-user-enumeration.test.ts +198 -0
- package/src/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.ts +130 -0
- package/src/tests/api_tests/security/F-0013-sanitize-user-html.test.ts +109 -0
- package/src/tests/api_tests/security/F-0016-prototype-pollution.test.ts +50 -0
- package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
- package/src/tests/setup.ts +8 -1
- package/src/tests/tests.ts +35 -5
- 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
|
+
}
|