@tellescope/sdk 1.256.6 → 1.256.7
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/enduser.d.ts +1 -1
- package/lib/cjs/enduser.d.ts.map +1 -1
- package/lib/cjs/enduser.js +1 -0
- package/lib/cjs/enduser.js.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +505 -0
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.js +173 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/form_started_trigger.test.js +87 -24
- package/lib/cjs/tests/api_tests/form_started_trigger.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/form_translations.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/form_translations.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/form_translations.test.js +259 -0
- package/lib/cjs/tests/api_tests/form_translations.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
- package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js +277 -0
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js +262 -78
- package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +230 -0
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +343 -80
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js +74 -35
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -1
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts +134 -0
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -1
- package/lib/cjs/tests/stripe_webhook_helpers.js +126 -1
- package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +238 -212
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +1 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/enduser.js +1 -0
- package/lib/esm/enduser.js.map +1 -1
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +501 -0
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.js +169 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.js.map +1 -0
- package/lib/esm/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/form_started_trigger.test.js +87 -24
- package/lib/esm/tests/api_tests/form_started_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/form_translations.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/form_translations.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/form_translations.test.js +255 -0
- package/lib/esm/tests/api_tests/form_translations.test.js.map +1 -0
- package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
- package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js +273 -0
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
- package/lib/esm/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/purchase_made_trigger.test.js +262 -78
- package/lib/esm/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +226 -0
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +344 -81
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
- package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/welle_partner_token.test.js +74 -35
- package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -1
- package/lib/esm/tests/stripe_webhook_helpers.d.ts +134 -0
- package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -1
- package/lib/esm/tests/stripe_webhook_helpers.js +119 -0
- package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +238 -212
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/enduser.ts +4 -2
- package/src/tests/api_tests/calendar_event_create_and_bulk_triggers.test.ts +293 -0
- package/src/tests/api_tests/configurations_public_read.test.ts +116 -0
- package/src/tests/api_tests/form_started_trigger.test.ts +49 -1
- package/src/tests/api_tests/form_translations.test.ts +205 -0
- package/src/tests/api_tests/group_event_attendee_status_triggers.test.ts +135 -0
- package/src/tests/api_tests/mdi_trigger_refill.test.ts +184 -0
- package/src/tests/api_tests/purchase_made_trigger.test.ts +137 -0
- package/src/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.ts +144 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +16 -6
- package/src/tests/api_tests/stripe_webhook_triggers.test.ts +221 -0
- package/src/tests/api_tests/welle_partner_token.test.ts +49 -6
- package/src/tests/stripe_webhook_helpers.ts +116 -0
- package/src/tests/tests.ts +27 -12
- package/test_generated.pdf +0 -0
|
@@ -111,6 +111,37 @@ export const purchase_made_trigger_tests = async ({ sdk, sdkNonAdmin } : { sdk:
|
|
|
111
111
|
const enduser13 = await sdk.api.endusers.createOne({})
|
|
112
112
|
const enduser14 = await sdk.api.endusers.createOne({})
|
|
113
113
|
|
|
114
|
+
// couponCodes filter triggers
|
|
115
|
+
const t10 = await sdk.api.automation_triggers.createOne({
|
|
116
|
+
event: { type: 'Purchase Made', info: { couponCodes: ['SUMMER20'] } },
|
|
117
|
+
action: { type: 'Add Tags', info: { tags: ['Purchase-Coupon-Summer20'] }},
|
|
118
|
+
status: 'Active',
|
|
119
|
+
title: "Purchase - Coupon SUMMER20"
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Empty couponCodes array should behave like no filter
|
|
123
|
+
const t11 = await sdk.api.automation_triggers.createOne({
|
|
124
|
+
event: { type: 'Purchase Made', info: { couponCodes: [] } },
|
|
125
|
+
action: { type: 'Add Tags', info: { tags: ['Purchase-Coupon-Empty'] }},
|
|
126
|
+
status: 'Active',
|
|
127
|
+
title: "Purchase - Empty couponCodes Array"
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Combined filter: title AND coupon must both match
|
|
131
|
+
const t12 = await sdk.api.automation_triggers.createOne({
|
|
132
|
+
event: { type: 'Purchase Made', info: { titles: ['Premium Course Package'], couponCodes: ['SUMMER20'] } },
|
|
133
|
+
action: { type: 'Add Tags', info: { tags: ['Purchase-Title-And-Coupon'] }},
|
|
134
|
+
status: 'Active',
|
|
135
|
+
title: "Purchase - Title and Coupon"
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const enduser15 = await sdk.api.endusers.createOne({})
|
|
139
|
+
const enduser16 = await sdk.api.endusers.createOne({})
|
|
140
|
+
const enduser17 = await sdk.api.endusers.createOne({})
|
|
141
|
+
const enduser18 = await sdk.api.endusers.createOne({})
|
|
142
|
+
const enduser19 = await sdk.api.endusers.createOne({})
|
|
143
|
+
const enduser20 = await sdk.api.endusers.createOne({})
|
|
144
|
+
|
|
114
145
|
// Test 1: Purchase without specific filters (should trigger t1 for any purchase)
|
|
115
146
|
const purchase1 = await sdk.api.purchases.createOne({
|
|
116
147
|
enduserId: enduser1.id,
|
|
@@ -321,6 +352,103 @@ export const purchase_made_trigger_tests = async ({ sdk, sdkNonAdmin } : { sdk:
|
|
|
321
352
|
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Any') && !e.tags?.includes('Purchase-No-Title-Test') }
|
|
322
353
|
)
|
|
323
354
|
|
|
355
|
+
// Test 15: exact coupon code match fires the coupon-scoped trigger
|
|
356
|
+
const purchase15 = await sdk.api.purchases.createOne({
|
|
357
|
+
enduserId: enduser15.id,
|
|
358
|
+
title: 'Coupon Test Purchase',
|
|
359
|
+
cost: { amount: 9999, currency: 'USD' },
|
|
360
|
+
processor: 'Stripe',
|
|
361
|
+
couponCodes: ['SUMMER20'],
|
|
362
|
+
})
|
|
363
|
+
await wait(undefined, 500)
|
|
364
|
+
await async_test(
|
|
365
|
+
"Purchase Made - Coupon Code Match (exact)",
|
|
366
|
+
() => sdk.api.endusers.getOne(enduser15.id),
|
|
367
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Coupon-Summer20') && !!e.tags?.includes('Purchase-Coupon-Empty') }
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
// Test 16: coupon match is case-sensitive — Stripe returns the canonical merchant-defined
|
|
371
|
+
// code, so a differently-cased value must NOT fire the coupon-scoped trigger
|
|
372
|
+
const purchase16 = await sdk.api.purchases.createOne({
|
|
373
|
+
enduserId: enduser16.id,
|
|
374
|
+
title: 'Coupon Test Purchase',
|
|
375
|
+
cost: { amount: 9999, currency: 'USD' },
|
|
376
|
+
processor: 'Stripe',
|
|
377
|
+
couponCodes: ['summer20'],
|
|
378
|
+
})
|
|
379
|
+
await wait(undefined, 500)
|
|
380
|
+
await async_test(
|
|
381
|
+
"Purchase Made - Coupon Code Case Mismatch (case sensitive, 'summer20' vs 'SUMMER20')",
|
|
382
|
+
() => sdk.api.endusers.getOne(enduser16.id),
|
|
383
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Any') && !e.tags?.includes('Purchase-Coupon-Summer20') }
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
// Test 17: a different coupon code does NOT fire the coupon-scoped trigger
|
|
387
|
+
const purchase17 = await sdk.api.purchases.createOne({
|
|
388
|
+
enduserId: enduser17.id,
|
|
389
|
+
title: 'Coupon Test Purchase',
|
|
390
|
+
cost: { amount: 9999, currency: 'USD' },
|
|
391
|
+
processor: 'Stripe',
|
|
392
|
+
couponCodes: ['WINTER10'],
|
|
393
|
+
})
|
|
394
|
+
await wait(undefined, 500)
|
|
395
|
+
await async_test(
|
|
396
|
+
"Purchase Made - Non-matching Coupon Code",
|
|
397
|
+
() => sdk.api.endusers.getOne(enduser17.id),
|
|
398
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Any') && !e.tags?.includes('Purchase-Coupon-Summer20') }
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
// Test 18: a purchase with no couponCodes never matches a coupon-scoped trigger, but
|
|
402
|
+
// unscoped and empty-array coupon triggers still fire (backward compat)
|
|
403
|
+
const purchase18 = await sdk.api.purchases.createOne({
|
|
404
|
+
enduserId: enduser18.id,
|
|
405
|
+
title: 'Coupon Test Purchase',
|
|
406
|
+
cost: { amount: 9999, currency: 'USD' },
|
|
407
|
+
processor: 'Stripe',
|
|
408
|
+
})
|
|
409
|
+
await wait(undefined, 500)
|
|
410
|
+
await async_test(
|
|
411
|
+
"Purchase Made - No Coupon Data (backward compat)",
|
|
412
|
+
() => sdk.api.endusers.getOne(enduser18.id),
|
|
413
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Any') && !!e.tags?.includes('Purchase-Coupon-Empty') && !e.tags?.includes('Purchase-Coupon-Summer20') }
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
// Test 19: combined titles + couponCodes filter fires when both match
|
|
417
|
+
const purchase19 = await sdk.api.purchases.createOne({
|
|
418
|
+
enduserId: enduser19.id,
|
|
419
|
+
title: 'Premium Course Package',
|
|
420
|
+
cost: { amount: 9999, currency: 'USD' },
|
|
421
|
+
processor: 'Stripe',
|
|
422
|
+
couponCodes: ['SUMMER20'],
|
|
423
|
+
})
|
|
424
|
+
await wait(undefined, 500)
|
|
425
|
+
await async_test(
|
|
426
|
+
"Purchase Made - Title AND Coupon Match",
|
|
427
|
+
() => sdk.api.endusers.getOne(enduser19.id),
|
|
428
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Title-And-Coupon') }
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
// Test 20: combined filter does NOT fire when the title matches but the coupon does not
|
|
432
|
+
const purchase20 = await sdk.api.purchases.createOne({
|
|
433
|
+
enduserId: enduser20.id,
|
|
434
|
+
title: 'Premium Course Package',
|
|
435
|
+
cost: { amount: 9999, currency: 'USD' },
|
|
436
|
+
processor: 'Stripe',
|
|
437
|
+
})
|
|
438
|
+
await wait(undefined, 500)
|
|
439
|
+
await async_test(
|
|
440
|
+
"Purchase Made - Title Matches But Coupon Missing (AND semantics)",
|
|
441
|
+
() => sdk.api.endusers.getOne(enduser20.id),
|
|
442
|
+
{ onResult: (e: Enduser) => !!e.tags?.includes('Purchase-Exact-Title') && !e.tags?.includes('Purchase-Title-And-Coupon') }
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
// Validator round-trip: couponCodes persists on the trigger's event.info
|
|
446
|
+
await async_test(
|
|
447
|
+
"Purchase Made - couponCodes round-trips on trigger info",
|
|
448
|
+
() => sdk.api.automation_triggers.getOne(t10.id),
|
|
449
|
+
{ onResult: t => JSON.stringify((t.event as any)?.info?.couponCodes) === JSON.stringify(['SUMMER20']) }
|
|
450
|
+
)
|
|
451
|
+
|
|
324
452
|
try {
|
|
325
453
|
// Clean up test data - triggers first
|
|
326
454
|
await sdk.api.automation_triggers.deleteOne(t1.id)
|
|
@@ -332,6 +460,9 @@ export const purchase_made_trigger_tests = async ({ sdk, sdkNonAdmin } : { sdk:
|
|
|
332
460
|
await sdk.api.automation_triggers.deleteOne(t7.id)
|
|
333
461
|
await sdk.api.automation_triggers.deleteOne(t8.id)
|
|
334
462
|
await sdk.api.automation_triggers.deleteOne(t9.id)
|
|
463
|
+
await sdk.api.automation_triggers.deleteOne(t10.id)
|
|
464
|
+
await sdk.api.automation_triggers.deleteOne(t11.id)
|
|
465
|
+
await sdk.api.automation_triggers.deleteOne(t12.id)
|
|
335
466
|
|
|
336
467
|
// Clean up products
|
|
337
468
|
await sdk.api.products.deleteOne(product1.id)
|
|
@@ -353,6 +484,12 @@ export const purchase_made_trigger_tests = async ({ sdk, sdkNonAdmin } : { sdk:
|
|
|
353
484
|
await sdk.api.endusers.deleteOne(enduser12.id)
|
|
354
485
|
await sdk.api.endusers.deleteOne(enduser13.id)
|
|
355
486
|
await sdk.api.endusers.deleteOne(enduser14.id)
|
|
487
|
+
await sdk.api.endusers.deleteOne(enduser15.id)
|
|
488
|
+
await sdk.api.endusers.deleteOne(enduser16.id)
|
|
489
|
+
await sdk.api.endusers.deleteOne(enduser17.id)
|
|
490
|
+
await sdk.api.endusers.deleteOne(enduser18.id)
|
|
491
|
+
await sdk.api.endusers.deleteOne(enduser19.id)
|
|
492
|
+
await sdk.api.endusers.deleteOne(enduser20.id)
|
|
356
493
|
} finally {}
|
|
357
494
|
}
|
|
358
495
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { ObjectId } from 'bson'
|
|
4
|
+
import { Session } from "../../../sdk"
|
|
5
|
+
import {
|
|
6
|
+
async_test,
|
|
7
|
+
log_header,
|
|
8
|
+
wait,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import { setup_tests } from "../../setup"
|
|
11
|
+
import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
12
|
+
|
|
13
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
14
|
+
const [nonAdminEmail, nonAdminPassword] = [process.env.NON_ADMIN_EMAIL, process.env.NON_ADMIN_PASSWORD]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Regression test for F-0006 (security-audit/findings/F-0006-zoom-and-benefit-verification-bypass-rbac.md),
|
|
18
|
+
* reopened 2026-08-17 for the `develop_health_run_benefit_verification` sub-item.
|
|
19
|
+
*
|
|
20
|
+
* `enduser_eligibility_results.develop_health_run_benefit_verification` is registered with
|
|
21
|
+
* `noAccessPermissions: true` (packages/private/api/api/v1/api.ts, ~line 24606). Before the fix,
|
|
22
|
+
* it had no in-handler check on `session.access?.endusers?.read` /
|
|
23
|
+
* `session.access?.enduser_eligibility_results?.create`, so it loaded the caller-supplied
|
|
24
|
+
* enduser's PHI via `DB.endusers.findById` and forwarded it to the Develop Health integration
|
|
25
|
+
* regardless of the caller's role. It also let a zero-access caller fingerprint an enduser's
|
|
26
|
+
* profile completeness via the distinct validation error messages returned for missing fields.
|
|
27
|
+
*
|
|
28
|
+
* This test:
|
|
29
|
+
* 1. Creates a role with explicit NO_ACCESS for `endusers` and `enduser_eligibility_results`.
|
|
30
|
+
* 2. Assigns the role to the non-admin user and re-authenticates.
|
|
31
|
+
* 3. Calls the endpoint with a fake enduserId — asserts a 403-equivalent access error, not the
|
|
32
|
+
* validation-error / integration-not-found path that would run pre-fix.
|
|
33
|
+
*
|
|
34
|
+
* Pre-fix: the handler proceeds past `DB.endusers.findById` and either 400s with a
|
|
35
|
+
* `validate_enduser_for_develop_health` message (enduser not found — since the id is fake) or,
|
|
36
|
+
* for a real target id, would load PHI and forward it to Develop Health. Either way, the access
|
|
37
|
+
* check never fires. Test fails (no access-denial signal).
|
|
38
|
+
*
|
|
39
|
+
* Post-fix: the handler throws 403 before touching `DB.endusers` at all. Test passes.
|
|
40
|
+
*/
|
|
41
|
+
export const develop_health_benefit_verification_rbac_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
42
|
+
log_header("F-0006: develop_health benefit verification RBAC regression")
|
|
43
|
+
|
|
44
|
+
const roleName = `f0006-develop-health-no-access-${Date.now()}`
|
|
45
|
+
let rbapId: string | undefined
|
|
46
|
+
const originalRoles = sdkNonAdmin.userInfo.roles
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// 1. Create a role that explicitly denies endusers access AND enduser_eligibility_results
|
|
50
|
+
// access. This is the realistic configuration the bypass affects: a tenant operator
|
|
51
|
+
// deliberately restricts a role from reading enduser PHI (e.g. for PHI-restricted
|
|
52
|
+
// integration/API-key roles).
|
|
53
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
54
|
+
role: roleName,
|
|
55
|
+
permissions: {
|
|
56
|
+
...PROVIDER_PERMISSIONS,
|
|
57
|
+
endusers: { create: null, read: null, update: null, delete: null },
|
|
58
|
+
enduser_eligibility_results: { create: null, read: null, update: null, delete: null },
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
rbapId = rbap.id
|
|
62
|
+
|
|
63
|
+
// 2. Assign the role to the non-admin user and re-authenticate so the new
|
|
64
|
+
// session reflects the role's denied permissions.
|
|
65
|
+
await sdk.api.users.updateOne(
|
|
66
|
+
sdkNonAdmin.userInfo.id,
|
|
67
|
+
{ roles: [roleName] },
|
|
68
|
+
{ replaceObjectFields: true },
|
|
69
|
+
)
|
|
70
|
+
await wait(undefined, 1500)
|
|
71
|
+
await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!)
|
|
72
|
+
|
|
73
|
+
// 3. develop_health_run_benefit_verification must throw 403 BEFORE any enduser lookup.
|
|
74
|
+
// Pre-fix: proceeds to DB.endusers.findById and either 400s on validation (fake id) or
|
|
75
|
+
// succeeds and forwards PHI to Develop Health (real id) — never a 403.
|
|
76
|
+
await async_test(
|
|
77
|
+
"F-0006: enduser_eligibility_results.develop_health_run_benefit_verification must throw 403 when role denies endusers/enduser_eligibility_results",
|
|
78
|
+
() => sdkNonAdmin.api.enduser_eligibility_results.develop_health_run_benefit_verification({
|
|
79
|
+
enduserId: new ObjectId().toHexString(), // fake id — access check should fire first
|
|
80
|
+
providerUserId: new ObjectId().toHexString(), // fake id — access check should fire first
|
|
81
|
+
insuranceType: 'primary',
|
|
82
|
+
diagnoses: [],
|
|
83
|
+
// drugs uses a plain (non-empty-ok) list validator, so a real-shaped entry is required
|
|
84
|
+
// to pass request validation and actually reach the handler's access check.
|
|
85
|
+
drugs: [{ name: 'Test Drug', dosage: '10mg', quantity: 1 }],
|
|
86
|
+
drug_history: { currently_taking_drugs: [], previously_taken_drugs: [] },
|
|
87
|
+
} as any),
|
|
88
|
+
{
|
|
89
|
+
shouldError: true,
|
|
90
|
+
onError: (e: any) => {
|
|
91
|
+
// Accept any 4xx access-denial response — handler uses 403 (recommended).
|
|
92
|
+
// Explicitly reject the pre-fix "Integration not found" / "Enduser is required" /
|
|
93
|
+
// similar validation messages, which would indicate the access check never fired.
|
|
94
|
+
const msg = (e?.message ?? '').toLowerCase()
|
|
95
|
+
const status = e?.status ?? e?.code
|
|
96
|
+
if (msg.includes('integration not found') || msg.includes('enduser is required')
|
|
97
|
+
|| msg.includes('organization') || msg.includes('provider')) {
|
|
98
|
+
return false // reached past the access gate — regression
|
|
99
|
+
}
|
|
100
|
+
return status === 403 || status === 401
|
|
101
|
+
|| msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
} finally {
|
|
106
|
+
// Cleanup: restore original roles, delete the test role.
|
|
107
|
+
try {
|
|
108
|
+
await sdk.api.users.updateOne(
|
|
109
|
+
sdkNonAdmin.userInfo.id,
|
|
110
|
+
{ roles: originalRoles ?? [] },
|
|
111
|
+
{ replaceObjectFields: true },
|
|
112
|
+
)
|
|
113
|
+
} catch {}
|
|
114
|
+
if (rbapId) {
|
|
115
|
+
try { await sdk.api.role_based_access_permissions.deleteOne(rbapId) } catch {}
|
|
116
|
+
}
|
|
117
|
+
// Re-authenticate the non-admin to drop the no-access role from their JWT
|
|
118
|
+
// before subsequent tests run.
|
|
119
|
+
await wait(undefined, 1500)
|
|
120
|
+
try { await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!) } catch {}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Allow running this test file independently
|
|
125
|
+
if (require.main === module) {
|
|
126
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
127
|
+
const sdk = new Session({ host })
|
|
128
|
+
const sdkNonAdmin = new Session({ host })
|
|
129
|
+
|
|
130
|
+
const runTests = async () => {
|
|
131
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
132
|
+
await develop_health_benefit_verification_rbac_tests({ sdk, sdkNonAdmin })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
runTests()
|
|
136
|
+
.then(() => {
|
|
137
|
+
console.log("✅ F-0006 develop_health_run_benefit_verification RBAC test suite completed successfully")
|
|
138
|
+
process.exit(0)
|
|
139
|
+
})
|
|
140
|
+
.catch((error) => {
|
|
141
|
+
console.error("❌ F-0006 develop_health_run_benefit_verification RBAC test suite failed:", error)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
@@ -327,12 +327,22 @@ export const public_endpoint_auth_field_lock_tests = async (
|
|
|
327
327
|
|
|
328
328
|
// Throwaway host user with all-7-day availability, so G9 has slots to find without depending on
|
|
329
329
|
// (or mutating) the shared test user's calendar configuration.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
330
|
+
// Stable email + get-or-create (rather than a per-run address): the cleanup below is not in a
|
|
331
|
+
// finally, and reset_db does not clear the users collection, so an interrupted run strands this
|
|
332
|
+
// user permanently. Reusing one address means at most one can ever exist — otherwise every
|
|
333
|
+
// interrupted run adds another provider to org-wide availability queries, which are capped at 25
|
|
334
|
+
// users sorted newest-first and would eventually evict the real test users.
|
|
335
|
+
const hostEmail = 'lock-host@tellescope.com'
|
|
336
|
+
const hostUser = (
|
|
337
|
+
await sdk.api.users.getOne({ email: hostEmail }).catch(() => null)
|
|
338
|
+
) || (
|
|
339
|
+
await sdk.api.users.createOne({
|
|
340
|
+
email: hostEmail,
|
|
341
|
+
verifiedEmail: true,
|
|
342
|
+
fname: 'Lock',
|
|
343
|
+
lname: 'Host',
|
|
344
|
+
})
|
|
345
|
+
)
|
|
336
346
|
createdUserIds.push(hostUser.id)
|
|
337
347
|
await sdk.api.users.updateOne(hostUser.id, {
|
|
338
348
|
weeklyAvailabilities: [0, 1, 2, 3, 4, 5, 6].map(dayOfWeekStartingSundayIndexedByZero => ({
|
|
@@ -4,7 +4,13 @@ import { Session } from "../../sdk"
|
|
|
4
4
|
import { assert, log_header, wait } from "@tellescope/testing"
|
|
5
5
|
import { setup_tests } from "../setup"
|
|
6
6
|
import {
|
|
7
|
+
charge_succeeded_event,
|
|
7
8
|
cleanup_stripe_test_integrations,
|
|
9
|
+
discount_with_coupon,
|
|
10
|
+
discount_with_promo_code,
|
|
11
|
+
discount_with_promo_code_ref,
|
|
12
|
+
invoice_paid_event,
|
|
13
|
+
invoice_paid_line,
|
|
8
14
|
invoice_payment_failed_event,
|
|
9
15
|
item_with_expanded_price_product,
|
|
10
16
|
item_with_plan_product,
|
|
@@ -640,6 +646,221 @@ export const stripe_webhook_triggers_tests = async ({ sdk, sdkNonAdmin } : { sdk
|
|
|
640
646
|
}
|
|
641
647
|
assert(rejectedInvalidPausedProductId, 'expected a non-ObjectId productId to be rejected', 'invalid Subscription Paused productId rejected')
|
|
642
648
|
|
|
649
|
+
/* ============ invoice.paid / charge.succeeded — coupon codes ============ */
|
|
650
|
+
|
|
651
|
+
// Purchases are created by the webhook handler itself, so persistence is polled the same way
|
|
652
|
+
// tags are. Cleaned up automatically when the enduser is deleted (onDependencyDelete: 'delete').
|
|
653
|
+
const poll_for_purchase = async (enduserId: string, iterations = 20) => {
|
|
654
|
+
for (let i = 0; i < iterations; i++) {
|
|
655
|
+
await wait(undefined, 500)
|
|
656
|
+
const purchases = await sdk.api.purchases.getSome({ filter: { enduserId } })
|
|
657
|
+
if (purchases.length) return purchases
|
|
658
|
+
}
|
|
659
|
+
return []
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// One webhook POST per case: a Purchase Made trigger (coupon-scoped unless couponCodes is
|
|
663
|
+
// undefined) and an invoice.paid built by the caller.
|
|
664
|
+
const run_coupon_invoice_case = async ({ label, couponCodes, discounts, discount, shouldFire, title } : {
|
|
665
|
+
label: string,
|
|
666
|
+
couponCodes?: string[],
|
|
667
|
+
discounts?: (object | string)[],
|
|
668
|
+
discount?: object,
|
|
669
|
+
shouldFire: boolean,
|
|
670
|
+
title: string,
|
|
671
|
+
}) => {
|
|
672
|
+
const caseTag = tag(label)
|
|
673
|
+
const enduser = await new_enduser(label)
|
|
674
|
+
await new_trigger({
|
|
675
|
+
title: `Stripe purchase made (${label}) ${RUN}`,
|
|
676
|
+
event: { type: 'Purchase Made', info: couponCodes !== undefined ? { couponCodes } : {} },
|
|
677
|
+
tags: [caseTag],
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
const res = await post_stripe_webhook({
|
|
681
|
+
stripePublicKey,
|
|
682
|
+
secret: webhooksSecret,
|
|
683
|
+
event: invoice_paid_event({
|
|
684
|
+
stripeCustomerId: enduser.stripeCustomerId,
|
|
685
|
+
lines: [invoice_paid_line({ description: `Coupon Test ${label} ${RUN}` })],
|
|
686
|
+
discounts,
|
|
687
|
+
discount,
|
|
688
|
+
}),
|
|
689
|
+
})
|
|
690
|
+
assert(res.status === 200, `expected 200, got ${res.status} (${res.data})`, `${label} webhook accepted`)
|
|
691
|
+
|
|
692
|
+
if (shouldFire) await expect_fired(enduser.id, caseTag, title)
|
|
693
|
+
else await expect_not_fired(enduser.id, caseTag, title)
|
|
694
|
+
|
|
695
|
+
return enduser
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// C1: the feature — a coupon-scoped trigger fires for an invoice discounted with that promo
|
|
699
|
+
// code, and the code is persisted on the purchase record. The expanded promotion_code object
|
|
700
|
+
// reflects the post-expansion shape (and the checkout-session breakdown shape), not the raw
|
|
701
|
+
// webhook — it's the only way to E2E-prove the promo-code match without outbound Stripe calls;
|
|
702
|
+
// C1b covers the raw shape.
|
|
703
|
+
const c1Enduser = await run_coupon_invoice_case({
|
|
704
|
+
label: 'c1-matching-promo',
|
|
705
|
+
couponCodes: ['SUMMER20'],
|
|
706
|
+
discounts: [discount_with_promo_code('SUMMER20')],
|
|
707
|
+
shouldFire: true,
|
|
708
|
+
title: 'coupon-scoped Purchase Made trigger fires for a matching promo code',
|
|
709
|
+
})
|
|
710
|
+
const c1Purchases = await poll_for_purchase(c1Enduser.id)
|
|
711
|
+
assert(
|
|
712
|
+
c1Purchases.some(p => (p as any).couponCodes?.includes('SUMMER20')),
|
|
713
|
+
`expected a purchase with couponCodes containing SUMMER20, got ${JSON.stringify(c1Purchases.map(p => (p as any).couponCodes))}`,
|
|
714
|
+
'couponCodes persisted on the webhook-created purchase',
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
// C1b: the raw webhook shape, verified against a live sandbox invoice.paid event — the single
|
|
718
|
+
// discount embedded at invoice.discount, only its bare "di_..." string id in invoice.discounts
|
|
719
|
+
// (the dedupe must not trigger a re-fetch), and promotion_code as a bare promo_... string whose
|
|
720
|
+
// retrieve fails under this suite's fake API key. The coupon name + id are still captured
|
|
721
|
+
// call-free, a name-scoped trigger fires, and the failed promo retrieve is proven non-fatal.
|
|
722
|
+
// (No absence assertion on the promo code: in production the retrieve succeeds.)
|
|
723
|
+
const c1bDiscount = discount_with_promo_code_ref({ couponId: `coupon_test_c1b_${RUN}`, couponName: 'Test coupon' })
|
|
724
|
+
const c1bEnduser = await run_coupon_invoice_case({
|
|
725
|
+
label: 'c1b-real-shape',
|
|
726
|
+
couponCodes: ['Test coupon'],
|
|
727
|
+
discount: c1bDiscount,
|
|
728
|
+
discounts: [(c1bDiscount as any).id],
|
|
729
|
+
shouldFire: true,
|
|
730
|
+
title: 'real-shaped invoice.paid payload matches via coupon name',
|
|
731
|
+
})
|
|
732
|
+
const c1bPurchases = await poll_for_purchase(c1bEnduser.id)
|
|
733
|
+
assert(
|
|
734
|
+
c1bPurchases.some(p =>
|
|
735
|
+
(p as any).couponCodes?.includes('Test coupon') && (p as any).couponCodes?.includes(`coupon_test_c1b_${RUN}`)
|
|
736
|
+
),
|
|
737
|
+
`expected couponCodes with the coupon name and id, got ${JSON.stringify(c1bPurchases.map(p => (p as any).couponCodes))}`,
|
|
738
|
+
'coupon name and id persisted from a real-shaped payload',
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
// C2: matching is case-sensitive — Stripe sends the canonical merchant-defined code, so a
|
|
742
|
+
// differently-cased trigger value must stay quiet (staff can list multiple variants if needed)
|
|
743
|
+
await run_coupon_invoice_case({
|
|
744
|
+
label: 'c2-case-mismatch',
|
|
745
|
+
couponCodes: ['summer20'],
|
|
746
|
+
discounts: [discount_with_promo_code('SUMMER20')],
|
|
747
|
+
shouldFire: false,
|
|
748
|
+
title: 'coupon match is case-sensitive: a differently-cased code does not fire',
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
// C3: a different code stays quiet
|
|
752
|
+
await run_coupon_invoice_case({
|
|
753
|
+
label: 'c3-nonmatching',
|
|
754
|
+
couponCodes: ['SUMMER20'],
|
|
755
|
+
discounts: [discount_with_promo_code('WINTER10')],
|
|
756
|
+
shouldFire: false,
|
|
757
|
+
title: 'coupon-scoped trigger does not fire for a different code',
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// C4: an undiscounted invoice never matches a coupon-scoped trigger
|
|
761
|
+
await run_coupon_invoice_case({
|
|
762
|
+
label: 'c4-no-discounts',
|
|
763
|
+
couponCodes: ['SUMMER20'],
|
|
764
|
+
shouldFire: false,
|
|
765
|
+
title: 'an invoice with no discounts does not fire a coupon-scoped trigger',
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
// C5: admin-applied coupons carry a name but no promotion code, and must still match. Built
|
|
769
|
+
// once and referenced by id from `discounts`, mirroring the real single-discount payload shape.
|
|
770
|
+
const c5Discount = discount_with_coupon('Spring Promo')
|
|
771
|
+
await run_coupon_invoice_case({
|
|
772
|
+
label: 'c5-coupon-name',
|
|
773
|
+
couponCodes: ['Spring Promo'],
|
|
774
|
+
discount: c5Discount,
|
|
775
|
+
discounts: [(c5Discount as any).id],
|
|
776
|
+
shouldFire: true,
|
|
777
|
+
title: 'coupon-scoped trigger matches via coupon name (no promotion code)',
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
// C6: backward compat — an unscoped Purchase Made trigger fires for discounted and
|
|
781
|
+
// undiscounted invoices alike
|
|
782
|
+
await run_coupon_invoice_case({
|
|
783
|
+
label: 'c6-unscoped-discounted',
|
|
784
|
+
discounts: [discount_with_promo_code('SUMMER20')],
|
|
785
|
+
shouldFire: true,
|
|
786
|
+
title: 'unscoped Purchase Made trigger fires for a discounted invoice',
|
|
787
|
+
})
|
|
788
|
+
await run_coupon_invoice_case({
|
|
789
|
+
label: 'c6b-unscoped-undiscounted',
|
|
790
|
+
shouldFire: true,
|
|
791
|
+
title: 'unscoped Purchase Made trigger fires for an undiscounted invoice',
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
// C7: charge.succeeded with payment_intent null — the coupon lookup is skipped entirely, so
|
|
795
|
+
// the purchase is created, the unscoped trigger fires, and the coupon-scoped one does not
|
|
796
|
+
const C7_TAG = tag('c7-charge-unscoped')
|
|
797
|
+
const C7_SCOPED_TAG = tag('c7-charge-coupon-scoped')
|
|
798
|
+
const c7Enduser = await new_enduser('c7')
|
|
799
|
+
await new_trigger({
|
|
800
|
+
title: `Stripe purchase made (c7 unscoped) ${RUN}`,
|
|
801
|
+
event: { type: 'Purchase Made', info: {} },
|
|
802
|
+
tags: [C7_TAG],
|
|
803
|
+
})
|
|
804
|
+
await new_trigger({
|
|
805
|
+
title: `Stripe purchase made (c7 coupon-scoped) ${RUN}`,
|
|
806
|
+
event: { type: 'Purchase Made', info: { couponCodes: ['SUMMER20'] } },
|
|
807
|
+
tags: [C7_SCOPED_TAG],
|
|
808
|
+
})
|
|
809
|
+
const c7Res = await post_stripe_webhook({
|
|
810
|
+
stripePublicKey,
|
|
811
|
+
secret: webhooksSecret,
|
|
812
|
+
event: charge_succeeded_event({
|
|
813
|
+
customer: {
|
|
814
|
+
id: c7Enduser.stripeCustomerId,
|
|
815
|
+
email: `stripe-webhook-c7-${RUN}@tellescope.com`,
|
|
816
|
+
name: 'Stripe Webhook c7',
|
|
817
|
+
},
|
|
818
|
+
}),
|
|
819
|
+
})
|
|
820
|
+
assert(c7Res.status === 200, `expected 200, got ${c7Res.status} (${c7Res.data})`, 'charge.succeeded webhook accepted')
|
|
821
|
+
await expect_fired(c7Enduser.id, C7_TAG, 'unscoped Purchase Made trigger fires for charge.succeeded')
|
|
822
|
+
await expect_not_fired(c7Enduser.id, C7_SCOPED_TAG, 'coupon-scoped trigger does not fire for a charge with no coupon data')
|
|
823
|
+
const c7Purchases = await poll_for_purchase(c7Enduser.id)
|
|
824
|
+
assert(c7Purchases.length > 0, 'expected charge.succeeded to create a purchase', 'charge.succeeded purchase created')
|
|
825
|
+
|
|
826
|
+
// C8: graceful degradation — a payment_intent string triggers the checkout-session lookup,
|
|
827
|
+
// which fails against real Stripe under this suite's fake API key. That failure must be caught:
|
|
828
|
+
// the purchase is still created and the unscoped trigger still fires.
|
|
829
|
+
const C8_TAG = tag('c8-charge-failed-lookup')
|
|
830
|
+
const c8Enduser = await new_enduser('c8')
|
|
831
|
+
await new_trigger({
|
|
832
|
+
title: `Stripe purchase made (c8 failed session lookup) ${RUN}`,
|
|
833
|
+
event: { type: 'Purchase Made', info: {} },
|
|
834
|
+
tags: [C8_TAG],
|
|
835
|
+
})
|
|
836
|
+
const c8Res = await post_stripe_webhook({
|
|
837
|
+
stripePublicKey,
|
|
838
|
+
secret: webhooksSecret,
|
|
839
|
+
event: charge_succeeded_event({
|
|
840
|
+
customer: {
|
|
841
|
+
id: c8Enduser.stripeCustomerId,
|
|
842
|
+
email: `stripe-webhook-c8-${RUN}@tellescope.com`,
|
|
843
|
+
name: 'Stripe Webhook c8',
|
|
844
|
+
},
|
|
845
|
+
payment_intent: `pi_test_${RUN}`,
|
|
846
|
+
}),
|
|
847
|
+
})
|
|
848
|
+
assert(c8Res.status === 200, `expected 200, got ${c8Res.status} (${c8Res.data})`, 'charge.succeeded with failing session lookup still accepted')
|
|
849
|
+
await expect_fired(c8Enduser.id, C8_TAG, 'a failing checkout-session lookup does not block the purchase or its triggers')
|
|
850
|
+
|
|
851
|
+
// C9: couponCodes must round-trip on the trigger's event.info
|
|
852
|
+
const c9Trigger = await new_trigger({
|
|
853
|
+
title: `Stripe purchase made (validator round-trip) ${RUN}`,
|
|
854
|
+
event: { type: 'Purchase Made', info: { couponCodes: ['SUMMER20'] } },
|
|
855
|
+
tags: [tag('c9')],
|
|
856
|
+
})
|
|
857
|
+
const c9Fetched = await sdk.api.automation_triggers.getOne(c9Trigger.id)
|
|
858
|
+
assert(
|
|
859
|
+
JSON.stringify((c9Fetched.event as any).info?.couponCodes) === JSON.stringify(['SUMMER20']),
|
|
860
|
+
`expected couponCodes to round-trip, got ${JSON.stringify(c9Fetched.event)}`,
|
|
861
|
+
'couponCodes round-trips through the validator',
|
|
862
|
+
)
|
|
863
|
+
|
|
643
864
|
console.log("All Stripe webhook trigger tests passed!")
|
|
644
865
|
} finally {
|
|
645
866
|
// Best-effort only: the harness exits the process on the first failed assertion, which skips
|
|
@@ -23,9 +23,13 @@ const decode_jwt_header = (token: string) => (
|
|
|
23
23
|
)
|
|
24
24
|
const decode_jwt_payload = (token: string) => (
|
|
25
25
|
JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) as {
|
|
26
|
-
|
|
26
|
+
sub: string,
|
|
27
|
+
iss: string,
|
|
28
|
+
aud: string,
|
|
29
|
+
jti: string,
|
|
27
30
|
iat: number,
|
|
28
31
|
exp: number,
|
|
32
|
+
target?: string,
|
|
29
33
|
}
|
|
30
34
|
)
|
|
31
35
|
const sign_hs256 = (signingInput: string, key: string) => (
|
|
@@ -99,16 +103,55 @@ export const welle_partner_token_tests = async ({ sdk, sdkNonAdmin } : { sdk: Se
|
|
|
99
103
|
|
|
100
104
|
const header = decode_jwt_header(issuedToken)
|
|
101
105
|
const payload = decode_jwt_payload(issuedToken)
|
|
102
|
-
const expectedExp = Math.floor(Date.now() / 1000) +
|
|
106
|
+
const expectedExp = Math.floor(Date.now() / 1000) + 60
|
|
103
107
|
|
|
104
108
|
return (
|
|
105
109
|
header.alg === 'HS256'
|
|
106
|
-
&& payload.
|
|
107
|
-
&&
|
|
110
|
+
&& payload.sub === HEALTHIE_PATIENT_ID
|
|
111
|
+
&& payload.iss === 'tellescope'
|
|
112
|
+
&& payload.aud === 'welle:sso-launch'
|
|
113
|
+
&& typeof payload.jti === 'string' && payload.jti.length > 0
|
|
114
|
+
&& Math.abs(payload.exp - expectedExp) < 10
|
|
108
115
|
)
|
|
109
116
|
}},
|
|
110
117
|
)
|
|
111
118
|
|
|
119
|
+
// each launch gets a unique jti
|
|
120
|
+
await async_test(
|
|
121
|
+
`back-to-back tokens carry distinct jti values`,
|
|
122
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token' }),
|
|
123
|
+
{ onResult: (r: any) => {
|
|
124
|
+
const second = r?.data?.token
|
|
125
|
+
if (!second) return false
|
|
126
|
+
return decode_jwt_payload(second).jti !== decode_jwt_payload(issuedToken).jti
|
|
127
|
+
}},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// optional deep-link target — allowlisted value is echoed as a claim
|
|
131
|
+
await async_test(
|
|
132
|
+
`target: 'plan' is included as a target claim`,
|
|
133
|
+
() => linkedSDK.api.integrations.proxy_read({ integration: WELLE_TITLE, type: 'partner-redirect-token', target: 'plan' }),
|
|
134
|
+
{ onResult: (r: any) => (
|
|
135
|
+
!!r?.data?.token && decode_jwt_payload(r.data.token).target === 'plan'
|
|
136
|
+
)},
|
|
137
|
+
)
|
|
138
|
+
// omitted target → no target claim at all (the happy-path token above was minted without one)
|
|
139
|
+
assert(
|
|
140
|
+
decode_jwt_payload(issuedToken).target === undefined,
|
|
141
|
+
"token minted without a target has a target claim",
|
|
142
|
+
"token minted without a target has no target claim",
|
|
143
|
+
)
|
|
144
|
+
// non-allowlisted target is rejected at the API boundary by schema validation
|
|
145
|
+
await async_test(
|
|
146
|
+
`invalid target is rejected by schema validation`,
|
|
147
|
+
() => linkedSDK.api.integrations.proxy_read({
|
|
148
|
+
integration: WELLE_TITLE,
|
|
149
|
+
type: 'partner-redirect-token',
|
|
150
|
+
target: 'admin' as any,
|
|
151
|
+
}),
|
|
152
|
+
{ shouldError: true, onError: () => true },
|
|
153
|
+
)
|
|
154
|
+
|
|
112
155
|
// 2) the signature is real — verifies against the stored signing key, and only that key
|
|
113
156
|
const [header, payload, signature] = issuedToken.split('.')
|
|
114
157
|
assert(
|
|
@@ -131,7 +174,7 @@ export const welle_partner_token_tests = async ({ sdk, sdkNonAdmin } : { sdk: Se
|
|
|
131
174
|
id: unlinkedId,
|
|
132
175
|
}),
|
|
133
176
|
{ onResult: (r: any) => (
|
|
134
|
-
!!r?.data?.token && decode_jwt_payload(r.data.token).
|
|
177
|
+
!!r?.data?.token && decode_jwt_payload(r.data.token).sub === HEALTHIE_PATIENT_ID
|
|
135
178
|
)},
|
|
136
179
|
)
|
|
137
180
|
|
|
@@ -195,7 +238,7 @@ export const welle_partner_token_tests = async ({ sdk, sdkNonAdmin } : { sdk: Se
|
|
|
195
238
|
if (!token) return false
|
|
196
239
|
const [h, p, s] = token.split('.')
|
|
197
240
|
return sign_hs256(`${h}.${p}`, SIGNING_KEY) === s
|
|
198
|
-
&& decode_jwt_payload(token).
|
|
241
|
+
&& decode_jwt_payload(token).sub === HEALTHIE_PATIENT_ID
|
|
199
242
|
}},
|
|
200
243
|
)
|
|
201
244
|
|