@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.
Files changed (115) hide show
  1. package/lib/cjs/enduser.d.ts +1 -1
  2. package/lib/cjs/enduser.d.ts.map +1 -1
  3. package/lib/cjs/enduser.js +1 -0
  4. package/lib/cjs/enduser.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +505 -0
  8. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/configurations_public_read.test.js +173 -0
  12. package/lib/cjs/tests/api_tests/configurations_public_read.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
  14. package/lib/cjs/tests/api_tests/form_started_trigger.test.js +87 -24
  15. package/lib/cjs/tests/api_tests/form_started_trigger.test.js.map +1 -1
  16. package/lib/cjs/tests/api_tests/form_translations.test.d.ts +6 -0
  17. package/lib/cjs/tests/api_tests/form_translations.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/form_translations.test.js +259 -0
  19. package/lib/cjs/tests/api_tests/form_translations.test.js.map +1 -0
  20. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
  21. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
  22. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
  23. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
  24. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
  25. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js +277 -0
  26. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
  27. package/lib/cjs/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
  28. package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js +262 -78
  29. package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
  30. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
  31. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
  32. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +230 -0
  33. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
  34. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
  35. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
  36. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
  37. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
  38. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +343 -80
  39. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
  40. package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
  41. package/lib/cjs/tests/api_tests/welle_partner_token.test.js +74 -35
  42. package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -1
  43. package/lib/cjs/tests/stripe_webhook_helpers.d.ts +134 -0
  44. package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -1
  45. package/lib/cjs/tests/stripe_webhook_helpers.js +126 -1
  46. package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -1
  47. package/lib/cjs/tests/tests.d.ts.map +1 -1
  48. package/lib/cjs/tests/tests.js +238 -212
  49. package/lib/cjs/tests/tests.js.map +1 -1
  50. package/lib/esm/enduser.d.ts +1 -1
  51. package/lib/esm/enduser.d.ts.map +1 -1
  52. package/lib/esm/enduser.js +1 -0
  53. package/lib/esm/enduser.js.map +1 -1
  54. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
  55. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
  56. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +501 -0
  57. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
  58. package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts +6 -0
  59. package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
  60. package/lib/esm/tests/api_tests/configurations_public_read.test.js +169 -0
  61. package/lib/esm/tests/api_tests/configurations_public_read.test.js.map +1 -0
  62. package/lib/esm/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
  63. package/lib/esm/tests/api_tests/form_started_trigger.test.js +87 -24
  64. package/lib/esm/tests/api_tests/form_started_trigger.test.js.map +1 -1
  65. package/lib/esm/tests/api_tests/form_translations.test.d.ts +6 -0
  66. package/lib/esm/tests/api_tests/form_translations.test.d.ts.map +1 -0
  67. package/lib/esm/tests/api_tests/form_translations.test.js +255 -0
  68. package/lib/esm/tests/api_tests/form_translations.test.js.map +1 -0
  69. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
  70. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
  71. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
  72. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
  73. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
  74. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js +273 -0
  75. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
  76. package/lib/esm/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
  77. package/lib/esm/tests/api_tests/purchase_made_trigger.test.js +262 -78
  78. package/lib/esm/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
  79. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
  80. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
  81. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +226 -0
  82. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
  83. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
  84. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
  85. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
  86. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
  87. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +344 -81
  88. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
  89. package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
  90. package/lib/esm/tests/api_tests/welle_partner_token.test.js +74 -35
  91. package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -1
  92. package/lib/esm/tests/stripe_webhook_helpers.d.ts +134 -0
  93. package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -1
  94. package/lib/esm/tests/stripe_webhook_helpers.js +119 -0
  95. package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -1
  96. package/lib/esm/tests/tests.d.ts.map +1 -1
  97. package/lib/esm/tests/tests.js +238 -212
  98. package/lib/esm/tests/tests.js.map +1 -1
  99. package/lib/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +10 -10
  101. package/src/enduser.ts +4 -2
  102. package/src/tests/api_tests/calendar_event_create_and_bulk_triggers.test.ts +293 -0
  103. package/src/tests/api_tests/configurations_public_read.test.ts +116 -0
  104. package/src/tests/api_tests/form_started_trigger.test.ts +49 -1
  105. package/src/tests/api_tests/form_translations.test.ts +205 -0
  106. package/src/tests/api_tests/group_event_attendee_status_triggers.test.ts +135 -0
  107. package/src/tests/api_tests/mdi_trigger_refill.test.ts +184 -0
  108. package/src/tests/api_tests/purchase_made_trigger.test.ts +137 -0
  109. package/src/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.ts +144 -0
  110. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +16 -6
  111. package/src/tests/api_tests/stripe_webhook_triggers.test.ts +221 -0
  112. package/src/tests/api_tests/welle_partner_token.test.ts +49 -6
  113. package/src/tests/stripe_webhook_helpers.ts +116 -0
  114. package/src/tests/tests.ts +27 -12
  115. 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
- const hostUser = await sdk.api.users.createOne({
331
- email: emailFor('lock-host'),
332
- verifiedEmail: true,
333
- fname: 'Lock',
334
- lname: `Host${RUN}`,
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
- patientId: string,
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) + 3600
106
+ const expectedExp = Math.floor(Date.now() / 1000) + 60
103
107
 
104
108
  return (
105
109
  header.alg === 'HS256'
106
- && payload.patientId === HEALTHIE_PATIENT_ID
107
- && Math.abs(payload.exp - expectedExp) < 60
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).patientId === HEALTHIE_PATIENT_ID
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).patientId === HEALTHIE_PATIENT_ID
241
+ && decode_jwt_payload(token).sub === HEALTHIE_PATIENT_ID
199
242
  }},
200
243
  )
201
244