@tellescope/sdk 1.251.0 → 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/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_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.map +1 -1
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +234 -198
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -1
- 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/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +186 -151
- package/lib/cjs/tests/tests.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_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.map +1 -1
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +235 -199
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -1
- 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/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +186 -151
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/calendar_event_webhook_template.test.ts +204 -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 +113 -88
- 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/tests.ts +19 -2
- 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,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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require('source-map-support').install();
|
|
2
2
|
|
|
3
|
-
import { Session } from "../../sdk"
|
|
3
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
4
4
|
import { log_header, wait, async_test } from "@tellescope/testing"
|
|
5
5
|
import { Enduser } from "@tellescope/types-client"
|
|
6
6
|
import { setup_tests } from "../setup"
|
|
@@ -52,110 +52,135 @@ export const push_forms_to_portal_group_completion_tests = async ({ sdk, sdkNonA
|
|
|
52
52
|
previousFields: [{ type: 'root', info: {} }],
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
// 2. Create a form group containing both forms
|
|
55
|
+
// 2. Create a form group containing both forms (shared across both submission flows)
|
|
56
56
|
const formGroup = await sdk.api.form_groups.createOne({
|
|
57
57
|
title: 'Push To Portal Test Group',
|
|
58
58
|
formIds: [formA.id, formB.id],
|
|
59
59
|
})
|
|
60
60
|
createdFormGroupIds.push(formGroup.id)
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
// Helper: run the full push-to-portal → submit → trigger flow with a configurable submitter.
|
|
63
|
+
// Each invocation creates its own trigger/journey/step/enduser and asserts its own tag.
|
|
64
|
+
// We test both the admin (user-session) submitter and the enduser (portal-session) submitter
|
|
65
|
+
// because they exercise different DB scopes in submit_form_response — and the regression QA caught
|
|
66
|
+
// was only triggered on the enduser-session path.
|
|
67
|
+
const runFlow = async ({ label, tag, submitAsEnduser } : { label: string, tag: string, submitAsEnduser: boolean }) => {
|
|
68
|
+
const trigger = await sdk.api.automation_triggers.createOne({
|
|
69
|
+
event: { type: 'Form Group Completed', info: { groupId: formGroup.id } },
|
|
70
|
+
action: { type: 'Add Tags', info: { tags: [tag] } },
|
|
71
|
+
status: 'Active',
|
|
72
|
+
title: `Form Group Completed - Push to Portal (${label})`,
|
|
73
|
+
})
|
|
74
|
+
createdTriggerIds.push(trigger.id)
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
createdJourneyIds.push(journey.id)
|
|
76
|
+
const journey = await sdk.api.journeys.createOne({
|
|
77
|
+
title: `Push To Portal Trigger Journey (${label})`,
|
|
78
|
+
})
|
|
79
|
+
createdJourneyIds.push(journey.id)
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
const pushStep = await sdk.api.automation_steps.createOne({
|
|
82
|
+
journeyId: journey.id,
|
|
83
|
+
action: { type: 'pushFormsToPortal', info: { formGroupIds: [formGroup.id] } },
|
|
84
|
+
events: [{ type: 'onJourneyStart', info: {} }],
|
|
85
|
+
})
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
createdEnduserIds.push(enduser.id)
|
|
87
|
+
const enduser = await sdk.api.endusers.createOne({ fname: 'PushPortal', lname: label })
|
|
88
|
+
createdEnduserIds.push(enduser.id)
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
await sdk.api.endusers.add_to_journey({
|
|
91
|
+
enduserIds: [enduser.id],
|
|
92
|
+
journeyId: journey.id,
|
|
93
|
+
})
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
const pushedResponses = await pollFor(
|
|
96
|
+
async () => {
|
|
97
|
+
const responses = await sdk.api.form_responses.getSome({
|
|
98
|
+
filter: { enduserId: enduser.id },
|
|
99
|
+
})
|
|
100
|
+
const pushed = responses.filter(r => !!r.pushedToPortalAt)
|
|
101
|
+
return pushed.length >= 2 ? pushed : undefined
|
|
102
|
+
},
|
|
103
|
+
(result): result is any[] => Array.isArray(result) && result.length >= 2,
|
|
104
|
+
`pushed-to-portal form_responses to be created by worker (${label})`,
|
|
105
|
+
500,
|
|
106
|
+
40,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for (const fr of pushedResponses) {
|
|
110
|
+
if (!fr.pushedToPortalAt) {
|
|
111
|
+
throw new Error(`Expected pushedToPortalAt to be set on form_response ${fr.id} (${label})`)
|
|
112
|
+
}
|
|
113
|
+
if (fr.groupId !== pushStep.id) {
|
|
114
|
+
throw new Error(`Expected form_response.groupId (${fr.groupId}) to equal automation step id (${pushStep.id}) (${label})`)
|
|
115
|
+
}
|
|
116
|
+
if (fr.automationStepId !== pushStep.id) {
|
|
117
|
+
throw new Error(`Expected form_response.automationStepId (${fr.automationStepId}) to equal automation step id (${pushStep.id}) (${label})`)
|
|
118
|
+
}
|
|
111
119
|
}
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
|
|
121
|
+
await async_test(
|
|
122
|
+
`Worker writes groupId === automationStepId and pushedToPortalAt set (${label})`,
|
|
123
|
+
async () => true,
|
|
124
|
+
{ onResult: r => r === true },
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// Build the submitter session
|
|
128
|
+
let submitterApi: typeof sdk.api | EnduserSession['api']
|
|
129
|
+
if (submitAsEnduser) {
|
|
130
|
+
const { authToken } = await sdk.api.endusers.generate_auth_token({ id: enduser.id })
|
|
131
|
+
const enduserSDK = new EnduserSession({ host, authToken, businessId: sdk.userInfo.businessId })
|
|
132
|
+
submitterApi = enduserSDK.api
|
|
133
|
+
} else {
|
|
134
|
+
submitterApi = sdk.api
|
|
114
135
|
}
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
|
|
137
|
+
for (const fr of pushedResponses) {
|
|
138
|
+
const isFormA = fr.formId === formA.id
|
|
139
|
+
const targetFieldId = isFormA ? fieldA.id : fieldB.id
|
|
140
|
+
const targetFieldTitle = isFormA ? 'FieldA' : 'FieldB'
|
|
141
|
+
await submitterApi.form_responses.submit_form_response({
|
|
142
|
+
accessCode: fr.accessCode as string,
|
|
143
|
+
responses: [{
|
|
144
|
+
fieldId: targetFieldId,
|
|
145
|
+
fieldTitle: targetFieldTitle,
|
|
146
|
+
answer: { type: 'string', value: 'pushed-portal-answer' },
|
|
147
|
+
}],
|
|
148
|
+
})
|
|
117
149
|
}
|
|
118
|
-
}
|
|
119
150
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
fieldTitle: targetFieldTitle,
|
|
137
|
-
answer: { type: 'string', value: 'pushed-portal-answer' },
|
|
138
|
-
}],
|
|
139
|
-
})
|
|
151
|
+
await pollFor(
|
|
152
|
+
async () => {
|
|
153
|
+
const e = await sdk.api.endusers.getOne(enduser.id)
|
|
154
|
+
return e.tags?.includes(tag) ? e : undefined
|
|
155
|
+
},
|
|
156
|
+
(result): result is Enduser => !!result,
|
|
157
|
+
`Form Group Completed trigger to apply tag after push-to-portal submissions (${label})`,
|
|
158
|
+
500,
|
|
159
|
+
30,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
await async_test(
|
|
163
|
+
`Form Group Completed trigger fires for push-to-portal completion (${label})`,
|
|
164
|
+
() => sdk.api.endusers.getOne(enduser.id),
|
|
165
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes(tag) },
|
|
166
|
+
)
|
|
140
167
|
}
|
|
141
168
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{ onResult: (e: Enduser) => !!e.tags?.includes('form-group-completed-push') },
|
|
158
|
-
)
|
|
169
|
+
// Admin submitter: simulates a staff user filling in the form on behalf of the patient
|
|
170
|
+
// (uses a user-scoped DB in submit_form_response).
|
|
171
|
+
await runFlow({
|
|
172
|
+
label: 'admin-submit',
|
|
173
|
+
tag: 'form-group-completed-push-admin',
|
|
174
|
+
submitAsEnduser: false,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Enduser submitter: simulates the patient submitting via the portal
|
|
178
|
+
// (uses an enduser-scoped DB in submit_form_response — exercises the path QA caught).
|
|
179
|
+
await runFlow({
|
|
180
|
+
label: 'enduser-submit',
|
|
181
|
+
tag: 'form-group-completed-push-enduser',
|
|
182
|
+
submitAsEnduser: true,
|
|
183
|
+
})
|
|
159
184
|
|
|
160
185
|
} finally {
|
|
161
186
|
for (const id of createdTriggerIds) {
|