@tellescope/sdk 1.256.2 → 1.256.4
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/metriport_push_form_response.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.js +298 -0
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.js +274 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts +18 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +964 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js +313 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -0
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts +204 -0
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -0
- package/lib/cjs/tests/stripe_webhook_helpers.js +286 -0
- package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +192 -180
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/translation.test.d.ts +3 -0
- package/lib/cjs/tests/unit_tests/translation.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/translation.test.js.map +1 -0
- package/lib/esm/enduser.d.ts +1 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +3 -2
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/session.d.ts +1 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.js +294 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.js.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.js +270 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.js.map +1 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts +18 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +960 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.js +309 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -0
- package/lib/esm/tests/stripe_webhook_helpers.d.ts +204 -0
- package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -0
- package/lib/esm/tests/stripe_webhook_helpers.js +267 -0
- package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +192 -180
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/translation.test.d.ts +3 -0
- package/lib/esm/tests/unit_tests/translation.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/translation.test.js +52 -0
- package/lib/esm/tests/unit_tests/translation.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/metriport_push_form_response.test.ts +226 -0
- package/src/tests/api_tests/stripe_webhook_triggers.test.ts +676 -0
- package/src/tests/api_tests/welle_partner_token.test.ts +249 -0
- package/src/tests/stripe_webhook_helpers.ts +240 -0
- package/src/tests/tests.ts +6 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { createHmac } from "crypto"
|
|
4
|
+
|
|
5
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
assert,
|
|
8
|
+
async_test,
|
|
9
|
+
log_header,
|
|
10
|
+
wait,
|
|
11
|
+
} from "@tellescope/testing"
|
|
12
|
+
import { HEALTHIE_TITLE, WELLE_TITLE } from "@tellescope/constants"
|
|
13
|
+
import { authenticate_enduser_via_token, setup_tests } from "../setup"
|
|
14
|
+
|
|
15
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
16
|
+
|
|
17
|
+
const SIGNING_KEY = 'welle_test_signing_key_do_not_use_in_prod'
|
|
18
|
+
const HEALTHIE_PATIENT_ID = '999888777'
|
|
19
|
+
|
|
20
|
+
// decoding needs no dependency: these tests assert on CLAIMS, and verify the signature separately with crypto
|
|
21
|
+
const decode_jwt_header = (token: string) => (
|
|
22
|
+
JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString())
|
|
23
|
+
)
|
|
24
|
+
const decode_jwt_payload = (token: string) => (
|
|
25
|
+
JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) as {
|
|
26
|
+
patientId: string,
|
|
27
|
+
iat: number,
|
|
28
|
+
exp: number,
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
const sign_hs256 = (signingInput: string, key: string) => (
|
|
32
|
+
createHmac('sha256', key).update(signingInput).digest('base64url')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// reset_db does NOT clear integrations, so a crashed prior run can leave a Welle record behind.
|
|
36
|
+
// The partner-token branch does findOne({ title: WELLE_TITLE }), so a stray record would make the
|
|
37
|
+
// "disconnected" assertion pass spuriously — clear them all rather than only the one we created.
|
|
38
|
+
const delete_all_welle_integrations = async (sdk: Session) => {
|
|
39
|
+
const existing = await sdk.api.integrations.getSome({})
|
|
40
|
+
for (const integration of existing.filter(i => i.title === WELLE_TITLE)) {
|
|
41
|
+
await sdk.api.integrations.deleteOne(integration.id).catch(console.error)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const welle_partner_token_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
46
|
+
log_header("Welle Partner Token Tests")
|
|
47
|
+
|
|
48
|
+
const suffix = Math.random().toString(36).slice(2, 10)
|
|
49
|
+
const linkedEmail = `welle.linked.${suffix}@tellescope.com`
|
|
50
|
+
const unlinkedEmail = `welle.unlinked.${suffix}@tellescope.com`
|
|
51
|
+
|
|
52
|
+
let linkedId = ''
|
|
53
|
+
let unlinkedId = ''
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await delete_all_welle_integrations(sdk)
|
|
57
|
+
|
|
58
|
+
await sdk.api.integrations.createOne({
|
|
59
|
+
title: WELLE_TITLE,
|
|
60
|
+
authentication: {
|
|
61
|
+
type: 'apiKey',
|
|
62
|
+
info: {
|
|
63
|
+
access_token: SIGNING_KEY,
|
|
64
|
+
refresh_token: 'unused',
|
|
65
|
+
scope: '',
|
|
66
|
+
token_type: 'Bearer',
|
|
67
|
+
expiry_date: 0,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// an enduser linked to Healthie — eligible for a partner token
|
|
73
|
+
const linked = await sdk.api.endusers.createOne({ email: linkedEmail, fname: 'Welle', lname: 'Linked' })
|
|
74
|
+
linkedId = linked.id
|
|
75
|
+
await sdk.api.endusers.updateOne(
|
|
76
|
+
linkedId,
|
|
77
|
+
{ references: [{ type: HEALTHIE_TITLE, id: HEALTHIE_PATIENT_ID }] } as any,
|
|
78
|
+
{ replaceObjectFields: true },
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// an enduser with no Healthie reference
|
|
82
|
+
const unlinked = await sdk.api.endusers.createOne({ email: unlinkedEmail, fname: 'Welle', lname: 'Unlinked' })
|
|
83
|
+
unlinkedId = unlinked.id
|
|
84
|
+
|
|
85
|
+
const linkedSDK = new EnduserSession({ host, businessId: sdk.userInfo.businessId })
|
|
86
|
+
await authenticate_enduser_via_token(sdk, linkedSDK, { email: linkedEmail })
|
|
87
|
+
|
|
88
|
+
const unlinkedSDK = new EnduserSession({ host, businessId: sdk.userInfo.businessId })
|
|
89
|
+
await authenticate_enduser_via_token(sdk, unlinkedSDK, { email: unlinkedEmail })
|
|
90
|
+
|
|
91
|
+
// 1) happy path — claims are correct
|
|
92
|
+
let issuedToken = ''
|
|
93
|
+
await async_test(
|
|
94
|
+
`enduser can mint a Welle partner token with the expected claims`,
|
|
95
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
96
|
+
{ onResult: (r: any) => {
|
|
97
|
+
issuedToken = r?.data?.token
|
|
98
|
+
if (!issuedToken) return false
|
|
99
|
+
|
|
100
|
+
const header = decode_jwt_header(issuedToken)
|
|
101
|
+
const payload = decode_jwt_payload(issuedToken)
|
|
102
|
+
const expectedExp = Math.floor(Date.now() / 1000) + 3600
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
header.alg === 'HS256'
|
|
106
|
+
&& payload.patientId === HEALTHIE_PATIENT_ID
|
|
107
|
+
&& Math.abs(payload.exp - expectedExp) < 60
|
|
108
|
+
)
|
|
109
|
+
}},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// 2) the signature is real — verifies against the stored signing key, and only that key
|
|
113
|
+
const [header, payload, signature] = issuedToken.split('.')
|
|
114
|
+
assert(
|
|
115
|
+
sign_hs256(`${header}.${payload}`, SIGNING_KEY) === signature,
|
|
116
|
+
"token signature does not verify against the stored Welle signing key",
|
|
117
|
+
"token is signed with the organization's Welle signing key",
|
|
118
|
+
)
|
|
119
|
+
assert(
|
|
120
|
+
sign_hs256(`${header}.${payload}`, `${SIGNING_KEY}_wrong`) !== signature,
|
|
121
|
+
"token signature verified against the WRONG key",
|
|
122
|
+
"token signature does not verify against a different key",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// 3) the subject is non-forgeable — the branch ignores the id param and uses the session's enduser
|
|
126
|
+
await async_test(
|
|
127
|
+
`enduser cannot mint a token for another enduser by passing id`,
|
|
128
|
+
() => linkedSDK.api.integrations.proxy_read({
|
|
129
|
+
integration: WELLE_TITLE,
|
|
130
|
+
type: 'partner-redirect-token',
|
|
131
|
+
id: unlinkedId,
|
|
132
|
+
}),
|
|
133
|
+
{ onResult: (r: any) => (
|
|
134
|
+
!!r?.data?.token && decode_jwt_payload(r.data.token).patientId === HEALTHIE_PATIENT_ID
|
|
135
|
+
)},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
// 4) an enduser with no Healthie reference gets no token
|
|
139
|
+
await async_test(
|
|
140
|
+
`enduser without a Healthie reference cannot mint a token`,
|
|
141
|
+
() => unlinkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
142
|
+
{ shouldError: true, onError: (e: any) => (e?.message || '').includes('enduser has no Healthie reference') },
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
// 5) staff sessions are not served by this branch (it is enduser-gated) — falls through to an empty result
|
|
146
|
+
await async_test(
|
|
147
|
+
`admin user session is not issued a Welle partner token`,
|
|
148
|
+
() => sdk.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
149
|
+
{ onResult: (r: any) => !r?.data?.token },
|
|
150
|
+
)
|
|
151
|
+
await async_test(
|
|
152
|
+
`non-admin user session is not issued a Welle partner token`,
|
|
153
|
+
() => sdkNonAdmin.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
154
|
+
{ onResult: (r: any) => !r?.data?.token },
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// 6) no other Welle proxy_read type is reachable by an enduser
|
|
158
|
+
await async_test(
|
|
159
|
+
`enduser cannot read other Welle proxy types`,
|
|
160
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'anything-else' }),
|
|
161
|
+
{ shouldError: true, onError: (e: any) => (
|
|
162
|
+
(e?.message || '').includes('enduser session type not valid for this integration type')
|
|
163
|
+
)},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// 7) with the integration disconnected, no token can be minted
|
|
167
|
+
await delete_all_welle_integrations(sdk)
|
|
168
|
+
await async_test(
|
|
169
|
+
`no token is issued once the Welle integration is disconnected`,
|
|
170
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
171
|
+
{ shouldError: true, onError: (e: any) => (e?.message || '').includes('Welle integration not found') },
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// 8) the real connect path — add_api_key_integration, not integrations.createOne.
|
|
175
|
+
// Welle has no credential-validation branch (an opaque HMAC key has nothing to authenticate
|
|
176
|
+
// against), so unlike Canvas/Candid/MDI this endpoint makes no upstream call and is testable.
|
|
177
|
+
await async_test(
|
|
178
|
+
`add_api_key_integration connects Welle`,
|
|
179
|
+
() => sdk.api.integrations.add_api_key_integration({ integration: WELLE_TITLE, API_KEY: SIGNING_KEY }),
|
|
180
|
+
{ onResult: (r: any) => r?.integration?.title === WELLE_TITLE },
|
|
181
|
+
)
|
|
182
|
+
await async_test(
|
|
183
|
+
`add_api_key_integration sets hasConnectedWelle`,
|
|
184
|
+
() => sdk.api.organizations.getOne(sdk.userInfo.businessId),
|
|
185
|
+
{ onResult: (o: any) => o?.hasConnectedWelle === true },
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// the key stored by add_api_key_integration must land in the field the proxy_read branch reads
|
|
189
|
+
// (authentication.info.access_token) — minting a verifiable token proves the two paths agree
|
|
190
|
+
await async_test(
|
|
191
|
+
`token minted from the add_api_key_integration credential verifies`,
|
|
192
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
193
|
+
{ onResult: (r: any) => {
|
|
194
|
+
const token = r?.data?.token
|
|
195
|
+
if (!token) return false
|
|
196
|
+
const [h, p, s] = token.split('.')
|
|
197
|
+
return sign_hs256(`${h}.${p}`, SIGNING_KEY) === s
|
|
198
|
+
&& decode_jwt_payload(token).patientId === HEALTHIE_PATIENT_ID
|
|
199
|
+
}},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// 9) the real disconnect path — remove_api_key_integration
|
|
203
|
+
await async_test(
|
|
204
|
+
`remove_api_key_integration disconnects Welle`,
|
|
205
|
+
() => sdk.api.integrations.remove_api_key_integration({ integration: WELLE_TITLE }),
|
|
206
|
+
{ onResult: () => true },
|
|
207
|
+
)
|
|
208
|
+
// the org flag update is fire-and-forget on this handler (res.end() returns before the .then chain)
|
|
209
|
+
await wait(undefined, 250)
|
|
210
|
+
await async_test(
|
|
211
|
+
`remove_api_key_integration clears hasConnectedWelle`,
|
|
212
|
+
() => sdk.api.organizations.getOne(sdk.userInfo.businessId),
|
|
213
|
+
{ onResult: (o: any) => !o?.hasConnectedWelle },
|
|
214
|
+
)
|
|
215
|
+
await async_test(
|
|
216
|
+
`no token is issued after remove_api_key_integration`,
|
|
217
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
218
|
+
{ shouldError: true, onError: (e: any) => (e?.message || '').includes('Welle integration not found') },
|
|
219
|
+
)
|
|
220
|
+
} finally {
|
|
221
|
+
await delete_all_welle_integrations(sdk).catch(console.error)
|
|
222
|
+
await sdk.api.organizations.updateOne(sdk.userInfo.businessId, { hasConnectedWelle: false }).catch(console.error)
|
|
223
|
+
for (const id of [linkedId, unlinkedId]) {
|
|
224
|
+
if (!id) continue
|
|
225
|
+
await sdk.api.endusers.deleteOne(id).catch(console.error)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Allow running this test file independently
|
|
231
|
+
if (require.main === module) {
|
|
232
|
+
const sdk = new Session({ host })
|
|
233
|
+
const sdkNonAdmin = new Session({ host })
|
|
234
|
+
|
|
235
|
+
const runTests = async () => {
|
|
236
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
237
|
+
await welle_partner_token_tests({ sdk, sdkNonAdmin })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
runTests()
|
|
241
|
+
.then(() => {
|
|
242
|
+
console.log("✅ Welle partner token test suite completed successfully")
|
|
243
|
+
process.exit(0)
|
|
244
|
+
})
|
|
245
|
+
.catch((error) => {
|
|
246
|
+
console.error("❌ Welle partner token test suite failed:", error)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
})
|
|
249
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
|
|
3
|
+
import { Session } from "../sdk"
|
|
4
|
+
|
|
5
|
+
const host = process.env.API_URL || 'http://localhost:8080'
|
|
6
|
+
|
|
7
|
+
// Marks the throwaway integrations created by tests so a run can clear residue left by an earlier
|
|
8
|
+
// run that exited early. The test harness calls process.exit(1) on the first failed assertion, which
|
|
9
|
+
// skips finally blocks, so cleanup cannot be relied on.
|
|
10
|
+
export const TEST_STRIPE_ENVIRONMENT = 'tellescope-test-stripe-webhooks'
|
|
11
|
+
|
|
12
|
+
// title must be exactly STRIPE_TITLE for the webhook handler to find the integration
|
|
13
|
+
const STRIPE_TITLE = 'Stripe'
|
|
14
|
+
|
|
15
|
+
let uniqueCounter = 0
|
|
16
|
+
const unique_suffix = () => `${Date.now()}_${uniqueCounter++}`
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Stripe signs `${timestamp}.${rawBody}` with HMAC-SHA256 and sends it hex-encoded in a
|
|
20
|
+
* `t=<unixSeconds>,v1=<hex>` header. Reproducing it here lets tests exercise the real webhook
|
|
21
|
+
* route — signature verification included — without Stripe or network access.
|
|
22
|
+
*
|
|
23
|
+
* The default timestamp is always in tolerance (Stripe allows 300s and only rejects old ones);
|
|
24
|
+
* pass an old timestamp explicitly to test rejection.
|
|
25
|
+
*/
|
|
26
|
+
export const sign_stripe_payload = (
|
|
27
|
+
rawBody: string,
|
|
28
|
+
secret: string,
|
|
29
|
+
timestamp = Math.floor(Date.now() / 1000),
|
|
30
|
+
) => (
|
|
31
|
+
`t=${timestamp},v1=${crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex')}`
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* POSTs an event to the custom Stripe webhook route.
|
|
36
|
+
*
|
|
37
|
+
* Serializes the body here and signs those exact bytes, because the route uses
|
|
38
|
+
* express.raw and the signature covers the raw body verbatim — handing a pre-serialized
|
|
39
|
+
* string to fetch is what keeps the two in sync.
|
|
40
|
+
*
|
|
41
|
+
* Pass `signature` to override the computed one (for rejection tests).
|
|
42
|
+
*/
|
|
43
|
+
export const post_stripe_webhook = async ({ stripePublicKey, event, secret, signature } : {
|
|
44
|
+
stripePublicKey: string,
|
|
45
|
+
event: object,
|
|
46
|
+
secret: string,
|
|
47
|
+
signature?: string,
|
|
48
|
+
}) => {
|
|
49
|
+
const rawBody = JSON.stringify(event)
|
|
50
|
+
|
|
51
|
+
const res = await fetch(`${host}/v1/webhooks/stripe/custom/${stripePublicKey}`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
// express.raw({ type: 'application/json' }) leaves req.body unset without this
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'stripe-signature': signature ?? sign_stripe_payload(rawBody, secret),
|
|
57
|
+
},
|
|
58
|
+
body: rawBody,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return { status: res.status, data: await res.text() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a throwaway custom Stripe integration and returns the values needed to sign webhooks for
|
|
66
|
+
* it, plus a cleanup function to call in a finally block.
|
|
67
|
+
*
|
|
68
|
+
* The access token is never used: neither invoice.payment_failed nor customer.subscription.deleted
|
|
69
|
+
* calls the Stripe API, and constructing a Stripe client doesn't validate the key or hit the network.
|
|
70
|
+
*/
|
|
71
|
+
export const setup_stripe_test_integration = async (sdk: Session) => {
|
|
72
|
+
// The handler looks up the integration across all businesses, so this must not collide with
|
|
73
|
+
// another tenant's real public key or with a concurrent run.
|
|
74
|
+
const stripePublicKey = `pk_test_tellescope_${unique_suffix()}`
|
|
75
|
+
const webhooksSecret = `whsec_test_${crypto.randomBytes(16).toString('hex')}`
|
|
76
|
+
|
|
77
|
+
await cleanup_stripe_test_integrations(sdk)
|
|
78
|
+
|
|
79
|
+
const integration = await sdk.api.integrations.createOne({
|
|
80
|
+
title: STRIPE_TITLE,
|
|
81
|
+
environment: TEST_STRIPE_ENVIRONMENT,
|
|
82
|
+
authentication: {
|
|
83
|
+
type: 'apiKey',
|
|
84
|
+
info: {
|
|
85
|
+
// all of these are required by integrationAuthenticationsValidator, and only
|
|
86
|
+
// external_id (the public key in the webhook URL) is actually read by the handler
|
|
87
|
+
access_token: 'sk_test_unused_by_this_path',
|
|
88
|
+
refresh_token: 'unused',
|
|
89
|
+
scope: '',
|
|
90
|
+
expiry_date: Date.now(),
|
|
91
|
+
token_type: 'Bearer',
|
|
92
|
+
external_id: stripePublicKey,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
// never readable afterwards (redactions: ['all']), so the caller must keep the returned value
|
|
96
|
+
webhooksSecret,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
stripePublicKey,
|
|
101
|
+
webhooksSecret,
|
|
102
|
+
integrationId: integration.id,
|
|
103
|
+
cleanup: () => sdk.api.integrations.deleteOne(integration.id),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Deletes any test Stripe integrations left behind by a run that exited before its cleanup. */
|
|
108
|
+
export const cleanup_stripe_test_integrations = async (sdk: Session) => {
|
|
109
|
+
const existing = await sdk.api.integrations.getSome({ filter: { title: STRIPE_TITLE } })
|
|
110
|
+
.catch(() => [])
|
|
111
|
+
|
|
112
|
+
await Promise.all(
|
|
113
|
+
existing
|
|
114
|
+
.filter(i => i.environment === TEST_STRIPE_ENVIRONMENT)
|
|
115
|
+
.map(i => sdk.api.integrations.deleteOne(i.id).catch(console.error))
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ------------------------------- event fixtures ------------------------------- */
|
|
120
|
+
|
|
121
|
+
// An invoice line item references its product through price.product on every modern line item, and
|
|
122
|
+
// through plan.product only on subscription/proration lines. Either may be a bare id or an expanded
|
|
123
|
+
// object, so each shape gets a builder.
|
|
124
|
+
export const line_with_price_product = (stripeProductId: string) => ({
|
|
125
|
+
price: { product: stripeProductId },
|
|
126
|
+
})
|
|
127
|
+
export const line_with_expanded_price_product = (stripeProductId: string) => ({
|
|
128
|
+
price: { product: { id: stripeProductId, object: 'product' } },
|
|
129
|
+
})
|
|
130
|
+
export const line_with_plan_product = (stripeProductId: string) => ({
|
|
131
|
+
price: null,
|
|
132
|
+
plan: { product: stripeProductId },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Minimal invoice.payment_failed envelope. constructEvent only requires valid JSON — it does not
|
|
137
|
+
* validate id/object/api_version — so this carries just the fields the handler reads.
|
|
138
|
+
*/
|
|
139
|
+
export const invoice_payment_failed_event = ({ stripeCustomerId, lines = [], has_more = false } : {
|
|
140
|
+
stripeCustomerId: string,
|
|
141
|
+
lines?: object[],
|
|
142
|
+
has_more?: boolean,
|
|
143
|
+
}) => ({
|
|
144
|
+
type: 'invoice.payment_failed',
|
|
145
|
+
data: {
|
|
146
|
+
object: {
|
|
147
|
+
id: `in_test_${unique_suffix()}`,
|
|
148
|
+
customer: stripeCustomerId,
|
|
149
|
+
lines: { object: 'list', data: lines, has_more },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/** Minimal customer.subscription.deleted envelope; the handler reads items.data[].plan.product. */
|
|
155
|
+
export const subscription_deleted_event = ({ stripeCustomerId, stripeProductIds = [] } : {
|
|
156
|
+
stripeCustomerId: string,
|
|
157
|
+
stripeProductIds?: string[],
|
|
158
|
+
}) => ({
|
|
159
|
+
type: 'customer.subscription.deleted',
|
|
160
|
+
data: {
|
|
161
|
+
object: {
|
|
162
|
+
id: `sub_test_${unique_suffix()}`,
|
|
163
|
+
customer: stripeCustomerId,
|
|
164
|
+
items: {
|
|
165
|
+
object: 'list',
|
|
166
|
+
data: stripeProductIds.map(product => ({ plan: { product } })),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A subscription item references its product through price.product on every modern item, and through
|
|
174
|
+
* plan.product on legacy ones. Either may be a bare id or an expanded object.
|
|
175
|
+
*/
|
|
176
|
+
export const item_with_price_product = (stripeProductId: string) => ({
|
|
177
|
+
price: { product: stripeProductId },
|
|
178
|
+
})
|
|
179
|
+
export const item_with_expanded_price_product = (stripeProductId: string) => ({
|
|
180
|
+
price: { product: { id: stripeProductId, object: 'product' } },
|
|
181
|
+
})
|
|
182
|
+
export const item_with_plan_product = (stripeProductId: string) => ({
|
|
183
|
+
price: null,
|
|
184
|
+
plan: { product: stripeProductId },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
/** A pause_collection value, as Stripe carries it on a paused-collection subscription. */
|
|
188
|
+
export const PAUSE_COLLECTION = { behavior: 'void', resumes_at: null }
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* customer.subscription.updated envelope. `previousAttributes` is the whole previous_attributes bag,
|
|
192
|
+
* so a case can express "pause_collection key absent" (pass {}) distinctly from
|
|
193
|
+
* "pause_collection was null" (pass { pause_collection: null }) — that distinction is the entire
|
|
194
|
+
* gate the handler uses to avoid running on every renewal.
|
|
195
|
+
*/
|
|
196
|
+
export const subscription_updated_event = ({
|
|
197
|
+
stripeCustomerId, items = [], previousAttributes = {}, pauseCollection = null, status = 'active',
|
|
198
|
+
} : {
|
|
199
|
+
stripeCustomerId: string,
|
|
200
|
+
items?: object[],
|
|
201
|
+
previousAttributes?: object,
|
|
202
|
+
pauseCollection?: object | null,
|
|
203
|
+
status?: string,
|
|
204
|
+
}) => ({
|
|
205
|
+
type: 'customer.subscription.updated',
|
|
206
|
+
data: {
|
|
207
|
+
object: {
|
|
208
|
+
id: `sub_test_${unique_suffix()}`,
|
|
209
|
+
customer: stripeCustomerId,
|
|
210
|
+
status,
|
|
211
|
+
pause_collection: pauseCollection,
|
|
212
|
+
items: { object: 'list', data: items, has_more: false },
|
|
213
|
+
},
|
|
214
|
+
previous_attributes: previousAttributes,
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The dedicated customer.subscription.paused / .resumed events, which Stripe emits only for
|
|
220
|
+
* status='paused' transitions. The handler fires unconditionally on these, so no previous_attributes.
|
|
221
|
+
*/
|
|
222
|
+
const subscription_pause_event = (type: string) => ({ stripeCustomerId, items = [], status } : {
|
|
223
|
+
stripeCustomerId: string,
|
|
224
|
+
items?: object[],
|
|
225
|
+
status?: string,
|
|
226
|
+
}) => ({
|
|
227
|
+
type,
|
|
228
|
+
data: {
|
|
229
|
+
object: {
|
|
230
|
+
id: `sub_test_${unique_suffix()}`,
|
|
231
|
+
customer: stripeCustomerId,
|
|
232
|
+
status: status ?? (type === 'customer.subscription.paused' ? 'paused' : 'active'),
|
|
233
|
+
pause_collection: null,
|
|
234
|
+
items: { object: 'list', data: items, has_more: false },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
export const subscription_paused_event = subscription_pause_event('customer.subscription.paused')
|
|
240
|
+
export const subscription_resumed_event = subscription_pause_event('customer.subscription.resumed')
|
package/src/tests/tests.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { translations_tests } from "./api_tests/translations.test"
|
|
|
41
41
|
import { embeddables_auth_token_tests } from "./api_tests/embeddables_auth_token.test"
|
|
42
42
|
import { user_portal_settings_tests } from "./api_tests/user_portal_settings.test"
|
|
43
43
|
import { integrations_redacted_tests } from "./api_tests/integrations_redacted.test"
|
|
44
|
+
import { welle_partner_token_tests } from "./api_tests/welle_partner_token.test"
|
|
44
45
|
import { healthie_multi_integration_tests } from "./api_tests/healthie_multi_integration.test"
|
|
45
46
|
import { get_some_projection_tests } from "./api_tests/get_some_projection.test"
|
|
46
47
|
import { file_download_unicode_names_tests } from "./api_tests/file_download_unicode_names.test"
|
|
@@ -150,8 +151,10 @@ import { openloop_webhooks_tests } from "./api_tests/openloop_webhooks.test";
|
|
|
150
151
|
import { beluga_pharmacy_mappings_tests } from "./api_tests/beluga_pharmacy_mappings.test";
|
|
151
152
|
import { mdi_case_offerings_tests } from "./api_tests/mdi_case_offerings.test";
|
|
152
153
|
import { beluga_manual_sync_tests } from "./api_tests/beluga_manual_sync.test";
|
|
154
|
+
import { metriport_push_form_response_tests } from "./api_tests/metriport_push_form_response.test";
|
|
153
155
|
import { beluga_trigger_refill_tests } from "./api_tests/beluga_trigger_refill.test";
|
|
154
156
|
import { mdi_webhooks_tests } from "./api_tests/mdi_webhooks.test";
|
|
157
|
+
import { stripe_webhook_triggers_tests } from "./api_tests/stripe_webhook_triggers.test";
|
|
155
158
|
import { account_switcher_tests } from "./api_tests/account_switcher.test";
|
|
156
159
|
import { totp_mfa_tests } from "./api_tests/totp_mfa.test";
|
|
157
160
|
import { set_fields_order_templates_tests } from "./api_tests/set_fields_order_templates.test";
|
|
@@ -15293,6 +15296,7 @@ const ip_address_form_tests = async () => {
|
|
|
15293
15296
|
await automation_trigger_tests()
|
|
15294
15297
|
await resource_access_tags_tests({ sdk, sdkNonAdmin })
|
|
15295
15298
|
await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
|
|
15299
|
+
await metriport_push_form_response_tests({ sdk, sdkNonAdmin })
|
|
15296
15300
|
await beluga_trigger_refill_tests({ sdk, sdkNonAdmin })
|
|
15297
15301
|
await beluga_pharmacy_mappings_tests({ sdk, sdkNonAdmin })
|
|
15298
15302
|
await mdi_case_offerings_tests({ sdk, sdkNonAdmin })
|
|
@@ -15338,7 +15342,9 @@ const ip_address_form_tests = async () => {
|
|
|
15338
15342
|
await phone_tree_enduser_condition_tests({ sdk, sdkNonAdmin })
|
|
15339
15343
|
await openloop_webhooks_tests({ sdk, sdkNonAdmin })
|
|
15340
15344
|
await mdi_webhooks_tests({ sdk, sdkNonAdmin })
|
|
15345
|
+
await stripe_webhook_triggers_tests({ sdk, sdkNonAdmin })
|
|
15341
15346
|
await integrations_redacted_tests({ sdk, sdkNonAdmin })
|
|
15347
|
+
await welle_partner_token_tests({ sdk, sdkNonAdmin })
|
|
15342
15348
|
await healthie_multi_integration_tests({ sdk, sdkNonAdmin })
|
|
15343
15349
|
await mdb_sort_tests({ sdk, sdkNonAdmin })
|
|
15344
15350
|
await search_tests()
|
package/test_generated.pdf
CHANGED
|
Binary file
|