@tellescope/sdk 1.256.13 → 1.256.14

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 (106) hide show
  1. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts +20 -0
  2. package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
  4. package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  6. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
  8. package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
  12. package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  14. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
  16. package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  18. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  19. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
  20. package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  21. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  22. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
  24. package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  26. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
  28. package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  29. package/lib/cjs/tests/setup_isolated.d.ts +68 -0
  30. package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
  31. package/lib/cjs/tests/setup_isolated.js +232 -0
  32. package/lib/cjs/tests/setup_isolated.js.map +1 -0
  33. package/lib/cjs/tests/tests.d.ts.map +1 -1
  34. package/lib/cjs/tests/tests.js +257 -209
  35. package/lib/cjs/tests/tests.js.map +1 -1
  36. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  37. package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  38. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
  39. package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  40. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  41. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  42. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
  43. package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  44. package/lib/esm/enduser.d.ts +1 -0
  45. package/lib/esm/enduser.d.ts.map +1 -1
  46. package/lib/esm/sdk.d.ts +3 -2
  47. package/lib/esm/sdk.d.ts.map +1 -1
  48. package/lib/esm/session.d.ts +1 -0
  49. package/lib/esm/session.d.ts.map +1 -1
  50. package/lib/esm/tests/api_tests/organization_signup.test.d.ts +20 -0
  51. package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
  52. package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
  53. package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
  54. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
  55. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
  56. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
  57. package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
  58. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
  59. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
  60. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
  61. package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
  62. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
  63. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
  64. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
  65. package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
  66. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
  67. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
  68. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
  69. package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
  70. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
  71. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
  72. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
  73. package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
  74. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
  75. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
  76. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
  77. package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
  78. package/lib/esm/tests/setup_isolated.d.ts +68 -0
  79. package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
  80. package/lib/esm/tests/setup_isolated.js +221 -0
  81. package/lib/esm/tests/setup_isolated.js.map +1 -0
  82. package/lib/esm/tests/tests.d.ts.map +1 -1
  83. package/lib/esm/tests/tests.js +257 -209
  84. package/lib/esm/tests/tests.js.map +1 -1
  85. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
  86. package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
  87. package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
  88. package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
  89. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
  90. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
  91. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
  92. package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
  93. package/lib/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +10 -10
  95. package/src/tests/api_tests/organization_signup.test.ts +240 -0
  96. package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
  97. package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
  98. package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
  99. package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
  100. package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
  101. package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
  102. package/src/tests/setup_isolated.ts +170 -0
  103. package/src/tests/tests.ts +25 -0
  104. package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
  105. package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -0
  106. package/test_generated.pdf +0 -0
@@ -0,0 +1,288 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+
5
+ import { Session } from "../../../sdk"
6
+ import {
7
+ assert,
8
+ log_header,
9
+ } from "@tellescope/testing"
10
+ import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
11
+ import { setup_tests } from "../../setup"
12
+
13
+ const host = process.env.API_URL || 'http://localhost:8080' as const
14
+
15
+ const RAND = () => Math.random().toString(36).slice(2, 10)
16
+
17
+ const ALL = 'All' as const
18
+ const FULL_ACCESS = { create: ALL, read: ALL, update: ALL, delete: ALL }
19
+
20
+ type Check = {
21
+ label: string,
22
+ pass: boolean,
23
+ detail: string,
24
+ }
25
+
26
+ /**
27
+ * Regression test for F-0191
28
+ * (security-audit/findings/F-0191-schema-adminonly-not-enforced.md).
29
+ *
30
+ * `adminOnly: true` on a CUSTOM action in schema.ts was documentation-only. The runtime gate
31
+ * (routing.ts:944) reads the flag off the options object built by getCustomEndpointFields, and
32
+ * api.ts:609 set `adminOnly: o.adminOnly` without ever consulting `action.adminOnly`. So every
33
+ * admin-only custom action had to declare the flag twice, and the copies desynced: 11 of 20
34
+ * declared custom actions were unenforced at runtime while both public doc artifacts
35
+ * (openapi.json and the unauthenticated GET /documentation feed, which read the SCHEMA flag)
36
+ * advertised "**Admin only.**".
37
+ *
38
+ * DEFAULT CRUD actions never had this bug — routing.ts:2434 etc. read
39
+ * model.defaultActions.<op>.adminOnly straight off the schema. The fix
40
+ * (`adminOnly: o.adminOnly ?? action.adminOnly`) makes custom actions behave the same way.
41
+ *
42
+ * Five of the 11 were de-flagged in the schema instead, because the flag itself was wrong and
43
+ * enforcing it would revert a deliberate decision: users.invite_user carries an explicit
44
+ * "no longer admin only, but controlled by users.create" and send_invitation_to_existing is its
45
+ * sibling; organizations.create_and_join is the self-service signup path and would 401 every new
46
+ * org (a fresh user has no roles — the handler is what grants Admin); sync_note_to_canvas is
47
+ * designed around `access: 'read'` for non-admin callers; background_errors.mark_read substitutes
48
+ * its own RBAC check that intentionally admits non-admins holding the verb.
49
+ *
50
+ * SAFETY: every probe fails EARLY inside its handler — after the adminOnly gate but before any
51
+ * vendor call. Pre-fix these requests really do reach the handler, and these handlers talk to
52
+ * Twilio, Athena and Canvas:
53
+ * - trigger_events : no `action` key + an absent triggerId => the loop `continue`s (inert)
54
+ * - athena-subscription: frequency 1 trips the `frequency < 5` guard on the handler's first line
55
+ * - sync-athena : 404s on the missing Athena integration (skipped if one exists)
56
+ * - link-twilio : takes NO parameters and can CREATE A REAL TWILIO SUBACCOUNT, so it is
57
+ * probed only when the org already has a twilioSID (handler short-circuits)
58
+ * - twilio-embed : no phoneNumberSid => 404 no-config or 400, both before any Twilio call
59
+ * - handle-autoreply : absent enduserId => `if (!enduser) return` (communications.ts:42)
60
+ * Input validation runs BEFORE the adminOnly gate (routing.ts:921 orders access, then validate),
61
+ * so every probe body must be schema-VALID or the test would prove nothing.
62
+ *
63
+ * RED (pre-fix): the six group-B rows fail — each reaches its handler and returns a non-401.
64
+ * GREEN (post-fix): all six return 401 "Admin access only"; the group-A controls are unchanged.
65
+ */
66
+ export const schema_adminonly_enforcement_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
67
+ log_header("F-0191: schema-declared adminOnly must be enforced on custom actions")
68
+
69
+ const checks: Check[] = []
70
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
71
+
72
+ // Raw axios: the SDK's parseError discards the HTTP status, and the status is the whole point.
73
+ const request = async (
74
+ authToken: string, method: 'get' | 'post', path: string, data?: any,
75
+ ) => {
76
+ const r = await axios.request({
77
+ method,
78
+ url: `${host}/v1${path}`,
79
+ ...method === 'get' ? { params: data } : { data },
80
+ headers: { Authorization: `Bearer ${authToken}` },
81
+ validateStatus: () => true,
82
+ })
83
+ return { status: r.status, body: r.data }
84
+ }
85
+
86
+ const isAdminDenial = (r: { status: number, body: any }) => (
87
+ r.status === 401 && r.body?.message === 'Admin access only'
88
+ )
89
+ const summarize = (r: { status: number, body: any }) => (
90
+ `${r.status} ${(JSON.stringify(r.body) ?? '').slice(0, 120)}`
91
+ )
92
+
93
+ let roleId = ''
94
+ let attackerId = ''
95
+ let enduserId = ''
96
+
97
+ try {
98
+ /* ================= fixtures ================= */
99
+
100
+ const role = `f0191-staff-${RAND()}`
101
+ const rbap = await sdk.api.role_based_access_permissions.createOne({
102
+ role,
103
+ permissions: {
104
+ ...PROVIDER_PERMISSIONS,
105
+ // grant exactly the verbs the actions under test declare, so a rejection proves the ADMIN
106
+ // gate fired rather than ordinary RBAC
107
+ automation_triggers: FULL_ACCESS,
108
+ organizations: FULL_ACCESS,
109
+ availability_blocks: FULL_ACCESS,
110
+ background_errors: FULL_ACCESS,
111
+ users: FULL_ACCESS,
112
+ },
113
+ } as any)
114
+ roleId = rbap.id
115
+
116
+ const attacker = await sdk.api.users.createOne({
117
+ email: `f0191-attacker-${RAND()}@tellescope.example`,
118
+ fname: 'F0191', lname: 'Staff',
119
+ verifiedEmail: true,
120
+ notificationEmailsDisabled: true,
121
+ roles: [role],
122
+ } as any)
123
+ attackerId = attacker.id
124
+
125
+ const attackerToken = (await sdk.api.users.generate_auth_token({ id: attackerId })).authToken
126
+ const adminToken = (await sdk.api.users.generate_auth_token({ id: sdk.userInfo.id })).authToken
127
+
128
+ const attackerSDK = new Session({ host, authToken: attackerToken })
129
+ await attackerSDK.refresh_session()
130
+ record(
131
+ 'setup: attacker is a non-admin holding every verb under test',
132
+ attackerSDK.userInfo.id === attackerId && !(attackerSDK.userInfo.roles ?? []).includes('Admin'),
133
+ `id=${attackerSDK.userInfo.id} roles=${JSON.stringify(attackerSDK.userInfo.roles)}`,
134
+ )
135
+
136
+ const enduser = await sdk.api.endusers.createOne({ fname: 'F0191', lname: 'Patient' })
137
+ enduserId = enduser.id
138
+
139
+ // A guaranteed-valid, guaranteed-absent id: create then delete, so probes hit the handler's
140
+ // "not found" branch rather than tripping input validation (which runs BEFORE the admin gate).
141
+ const doomed = await sdk.api.endusers.createOne({ fname: 'F0191', lname: 'Absent' })
142
+ const ABSENT_ID = doomed.id
143
+ await sdk.api.endusers.deleteOne(ABSENT_ID)
144
+
145
+ /* ===== environment guards for the two probes input alone cannot make safe ===== */
146
+
147
+ let athenaIntegrationExists = false
148
+ try {
149
+ const existing = await sdk.api.integrations.load_redacted({})
150
+ athenaIntegrationExists = !!existing.integrations.find((i: any) => /athena/i.test(i.title ?? ''))
151
+ } catch { /* load not permitted — assume absent */ }
152
+
153
+ let orgHasTwilioSID = false
154
+ try {
155
+ const org = await sdk.api.organizations.getOne(sdk.userInfo.businessId)
156
+ orgHasTwilioSID = !!(org as any)?.twilioSID
157
+ } catch { /* ignore */ }
158
+
159
+ /* ===== GROUP B: the six genuine gaps — each must become 401 ===== */
160
+
161
+ const groupB: {
162
+ label: string, method: 'get' | 'post', path: string, data?: any, skip?: string,
163
+ }[] = [
164
+ {
165
+ label: 'automation_triggers.trigger_events',
166
+ method: 'post', path: '/automation-triggers/trigger-events',
167
+ // no `action` key: stays off the arbitrary-action-execution branch entirely
168
+ data: { triggers: [{ enduserId, automationTriggerId: ABSENT_ID }] },
169
+ },
170
+ {
171
+ label: 'organizations.add_athena_subscription',
172
+ method: 'post', path: '/organizations/athena-subscription',
173
+ data: { type: 'patients', frequency: 1 }, // trips `frequency < 5` on the handler's first line
174
+ },
175
+ {
176
+ label: 'organizations.sync_athena_subscription',
177
+ method: 'post', path: '/organizations/sync-athena-subscription',
178
+ data: { type: 'patients' },
179
+ skip: athenaIntegrationExists
180
+ ? 'org HAS an Athena integration — probing would call Athena.process_subscription for real'
181
+ : undefined,
182
+ },
183
+ {
184
+ label: 'organizations.link_twilio',
185
+ method: 'post', path: '/organizations/link-twilio',
186
+ data: {},
187
+ skip: orgHasTwilioSID
188
+ ? undefined
189
+ : 'org has no twilioSID — probing would CREATE A REAL TWILIO SUBACCOUNT',
190
+ },
191
+ {
192
+ label: 'organizations.load_twilio_embed',
193
+ method: 'get', path: '/organizations/twilio-embed',
194
+ data: { type: 'tfn-verification' }, // no phoneNumberSid => rejected before any Twilio call
195
+ },
196
+ {
197
+ label: 'availability_blocks.handle_autoreply',
198
+ method: 'post', path: '/handle-out-of-office-autoreply',
199
+ data: { channel: 'Email', enduserId: ABSENT_ID }, // communications.ts:42 `if (!enduser) return`
200
+ },
201
+ ]
202
+
203
+ for (const probe of groupB) {
204
+ if (probe.skip) {
205
+ record(`B: ${probe.label} rejects a non-admin`, true, `SKIPPED (safety) — ${probe.skip}`)
206
+ continue
207
+ }
208
+
209
+ const attackerResult = await request(attackerToken, probe.method, probe.path, probe.data)
210
+ // baseline: an admin must NOT be admin-denied on the same path, so a blanket 401 can't pass
211
+ const baseline = await request(adminToken, probe.method, probe.path, probe.data)
212
+ const badProbe = isAdminDenial(baseline)
213
+
214
+ record(
215
+ `B: ${probe.label} rejects a non-admin`,
216
+ !badProbe && isAdminDenial(attackerResult),
217
+ `attacker=${summarize(attackerResult)} | admin baseline=${baseline.status}`
218
+ + (badProbe ? ' BAD PROBE: admin was also admin-denied' : ''),
219
+ )
220
+ }
221
+
222
+ /* ===== GROUP A: de-flagged actions must NOT become admin-only ===== */
223
+
224
+ const groupA: { label: string, method: 'get' | 'post', path: string, data?: any }[] = [
225
+ {
226
+ label: 'organizations.sync_note_to_canvas',
227
+ method: 'post', path: '/organizations/sync-note-to-canvas',
228
+ data: { enduserId: ABSENT_ID, note: 'F-0191 control' }, // 404 Enduser not found
229
+ },
230
+ {
231
+ label: 'background_errors.mark_read',
232
+ method: 'post', path: '/background-errors/mark-read', data: {},
233
+ },
234
+ {
235
+ label: 'users.send_invitation_to_existing',
236
+ method: 'post', path: '/invite-existing-user-to-organization',
237
+ // takes userId (not email); an absent id hits RecordNotFoundError, well short of any invite
238
+ data: { userId: ABSENT_ID },
239
+ },
240
+ ]
241
+
242
+ for (const probe of groupA) {
243
+ const r = await request(attackerToken, probe.method, probe.path, probe.data)
244
+ record(
245
+ `A: ${probe.label} stays reachable by a non-admin (flag intentionally dropped)`,
246
+ !isAdminDenial(r),
247
+ `${summarize(r)} (expected a handler response, not 401 Admin access only)`,
248
+ )
249
+ }
250
+
251
+ /* ===== report: print full table first, then assert (harness exits on first failure) ===== */
252
+ console.log('\n F-0191 check summary:')
253
+ for (const c of checks) {
254
+ console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
255
+ }
256
+ console.log('')
257
+
258
+ for (const c of checks) {
259
+ assert(c.pass, `${c.label} — ${c.detail}`, c.label)
260
+ }
261
+ } finally {
262
+ if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
263
+ if (attackerId) { try { await sdk.api.users.deleteOne(attackerId) } catch {} }
264
+ if (roleId) { try { await sdk.api.role_based_access_permissions.deleteOne(roleId) } catch {} }
265
+ }
266
+ }
267
+
268
+ // Allow running this test file independently
269
+ if (require.main === module) {
270
+ console.log(`🌐 Using API URL: ${host}`)
271
+ const sdk = new Session({ host })
272
+ const sdkNonAdmin = new Session({ host })
273
+
274
+ const runTests = async () => {
275
+ await setup_tests(sdk, sdkNonAdmin)
276
+ await schema_adminonly_enforcement_tests({ sdk, sdkNonAdmin })
277
+ }
278
+
279
+ runTests()
280
+ .then(() => {
281
+ console.log("✅ F-0191 schema adminOnly enforcement test suite completed successfully")
282
+ process.exit(0)
283
+ })
284
+ .catch((error) => {
285
+ console.error("❌ F-0191 schema adminOnly enforcement test suite failed:", error)
286
+ process.exit(1)
287
+ })
288
+ }
@@ -0,0 +1,238 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+
5
+ import { Session } from "../../../sdk"
6
+ import {
7
+ assert,
8
+ log_header,
9
+ } from "@tellescope/testing"
10
+ import { PROVIDER_PERMISSIONS, PLACEHOLDER_ID } from "@tellescope/constants"
11
+ import { setup_tests } from "../../setup"
12
+
13
+ const host = process.env.API_URL || 'http://localhost:8080' as const
14
+
15
+ const RAND = () => Math.random().toString(36).slice(2, 10)
16
+
17
+ const ALL = 'All' as const
18
+ const FULL_ACCESS = { create: ALL, read: ALL, update: ALL, delete: ALL }
19
+
20
+ const PROCESS_PATH = '/v1/automated-actions/process'
21
+
22
+ // A single collected check. Everything is gathered first and asserted at the end, because the
23
+ // testing harness exits the process on the first failed assert — collecting first means a RED run
24
+ // prints the full evidence table (and reaches `finally` cleanup) before the first assert exits.
25
+ type Check = {
26
+ label: string,
27
+ pass: boolean,
28
+ detail: string,
29
+ }
30
+
31
+ /**
32
+ * Regression test for F-0192
33
+ * (security-audit/findings/F-0192-automated-actions-process-fire-replay.md).
34
+ *
35
+ * `automated_actions.process` (api.ts:3902) is the WORKER's action runner — its schema description
36
+ * reads "Used by worker for new action types". It was registered with only
37
+ * `{ requiresSubscription: true }`, so the framework gated it on the ordinary
38
+ * `automated_actions.update` verb, which the default provider role holds ('Assigned'). The handler
39
+ * then dispatched `automatedAction.action` through a 14-branch if-chain reaching real vendors:
40
+ * chargebeeChargeCardOnFile (card charge), belugaAutoRx / belugaTriggerRefill / mdiTriggerRefill
41
+ * (prescriptions and refills), createScriptSureDraft (medication draft), healthiePushForms /
42
+ * metriportPushFormResponse (PHI export). It checked neither the row's `status` nor whether it had
43
+ * already run, so any row was replayable: MDI advances to the next scheduled refill each call,
44
+ * Chargebee charges again. And there was no storeUserLog, so the action was attributed to the
45
+ * worker rather than the caller.
46
+ *
47
+ * The fix has three parts:
48
+ * 1. `adminOnly: true` on the api.ts registration (routing.ts:944 reads the flag from that
49
+ * options object — a schema-only flag is inert, which is the separate finding F-0191).
50
+ * 2. A single-fire claim for human callers: an atomic findOneAndUpdate with `status: 'active'` in
51
+ * the filter, stamping `finished` + `processedAt` before the vendor dispatch. The worker
52
+ * (`wasAutomated`) is exempt because its own claim in
53
+ * worker/modules/database.ts:114 is already atomic and it needs the row to stay 'active'
54
+ * across the call so a retryable failure (worker/modules/automations.ts:2053) can be
55
+ * re-claimed after the lock times out.
56
+ * 3. storeUserLog on the human path, recording the caller and the automatedActionId.
57
+ *
58
+ * The seeded row uses `setEnduserStatus`, which is NOT one of the API's if-chain branches, so it
59
+ * falls through to `{ success: false, unimplemented: true }` (api.ts) — no vendor call, no Bedrock,
60
+ * no PHI export. `processAfter` is set an hour out so the live worker cannot race the row (the same
61
+ * trick mdi_trigger_refill / beluga_trigger_refill use).
62
+ *
63
+ * RED (pre-fix): A1 fails (a non-admin holding only `automated_actions.update` reaches the handler)
64
+ * and C1 fails (the second call on the same row executes again instead of being rejected).
65
+ * B1 (admin positive control) passes pre-fix and must keep passing.
66
+ * GREEN (post-fix): all five checks pass.
67
+ */
68
+ export const automated_actions_process_gate_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
69
+ log_header("F-0192: automated_actions.process must be admin-only and single-fire")
70
+
71
+ const checks: Check[] = []
72
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
73
+
74
+ // Raw axios: the SDK's parseError discards the HTTP status, and the whole point here is WHICH
75
+ // status the gate returns.
76
+ const post = async (authToken: string, automatedActionId: string) => {
77
+ const r = await axios.request({
78
+ method: 'post',
79
+ url: `${host}${PROCESS_PATH}`,
80
+ data: { automatedActionId },
81
+ headers: { Authorization: `Bearer ${authToken}` },
82
+ validateStatus: () => true,
83
+ })
84
+ return { status: r.status, body: r.data }
85
+ }
86
+
87
+ let roleId = ''
88
+ let attackerId = ''
89
+ let enduserId = ''
90
+ const automatedActionIds: string[] = []
91
+
92
+ try {
93
+ /* ================= fixtures ================= */
94
+
95
+ // A non-admin role that explicitly HOLDS automated_actions update — the point of the finding is
96
+ // that holding the declared verb was enough to fire vendor actions.
97
+ const role = `f0192-automations-${RAND()}`
98
+ const rbap = await sdk.api.role_based_access_permissions.createOne({
99
+ role,
100
+ permissions: { ...PROVIDER_PERMISSIONS, automated_actions: FULL_ACCESS },
101
+ } as any)
102
+ roleId = rbap.id
103
+
104
+ // Throwaway staff user — never mutate an existing user's roles. verifiedEmail must be set at
105
+ // create time (it is rejected on update), otherwise calls are blocked on email verification
106
+ // rather than on the gate under test.
107
+ const attacker = await sdk.api.users.createOne({
108
+ email: `f0192-attacker-${RAND()}@tellescope.example`,
109
+ fname: 'F0192', lname: 'Staff',
110
+ verifiedEmail: true,
111
+ notificationEmailsDisabled: true,
112
+ roles: [role],
113
+ } as any)
114
+ attackerId = attacker.id
115
+
116
+ // Admin-minted token, not .authenticate — /login is IP rate-limited and the suite shares an IP.
117
+ const attackerToken = (await sdk.api.users.generate_auth_token({ id: attackerId })).authToken
118
+ const adminToken = (await sdk.api.users.generate_auth_token({ id: sdk.userInfo.id })).authToken
119
+
120
+ const attackerSDK = new Session({ host, authToken: attackerToken })
121
+ await attackerSDK.refresh_session()
122
+ record(
123
+ 'setup: attacker session is a non-admin holding automated_actions.update',
124
+ attackerSDK.userInfo.id === attackerId && !(attackerSDK.userInfo.roles ?? []).includes('Admin'),
125
+ `id=${attackerSDK.userInfo.id} roles=${JSON.stringify(attackerSDK.userInfo.roles)}`,
126
+ )
127
+
128
+ const enduser = await sdk.api.endusers.createOne({ fname: 'F0192', lname: 'Patient' })
129
+ enduserId = enduser.id
130
+
131
+ // `setEnduserStatus` is handled by the worker, NOT by the API's process if-chain, so it falls
132
+ // through to `unimplemented: true`. processAfter is an hour out so the live worker cannot claim
133
+ // the row before the explicit calls below.
134
+ const seedAction = async () => {
135
+ const a = await sdk.api.automated_actions.createOne({
136
+ enduserId,
137
+ journeyId: PLACEHOLDER_ID,
138
+ automationStepId: PLACEHOLDER_ID,
139
+ event: { type: 'onJourneyStart', info: {} },
140
+ action: { type: 'setEnduserStatus', info: { status: 'Working' } },
141
+ status: 'active',
142
+ processAfter: Date.now() + 60 * 60 * 1000,
143
+ } as any)
144
+ automatedActionIds.push(a.id)
145
+ return a
146
+ }
147
+
148
+ /* ===== A: the privilege gap — a non-admin with the declared verb must be rejected ===== */
149
+
150
+ const attackerRow = await seedAction()
151
+ const attackerAttempt = await post(attackerToken, attackerRow.id)
152
+
153
+ // Baseline on the same path with an admin token, so a blanket 401 (expired subscription,
154
+ // bad route, broken fixture) cannot masquerade as a working gate.
155
+ const baselineRow = await seedAction()
156
+ const baseline = await post(adminToken, baselineRow.id)
157
+ const badProbe = baseline.status === 401 || baseline.status === 403
158
+
159
+ record(
160
+ 'A1: a non-admin holding automated_actions.update cannot call process',
161
+ !badProbe && (attackerAttempt.status === 401 || attackerAttempt.status === 403),
162
+ `attacker=${attackerAttempt.status} ${JSON.stringify(attackerAttempt.body)} baseline(admin)=${baseline.status}`
163
+ + (badProbe ? ' BAD PROBE: admin baseline was also rejected' : ''),
164
+ )
165
+
166
+ // The attacker's row must be untouched — the gate has to fire before the claim and the dispatch.
167
+ const attackerRowAfter = await sdk.api.automated_actions.getOne(attackerRow.id)
168
+ record(
169
+ 'A2: a rejected non-admin call leaves the row unfired (status still active)',
170
+ attackerRowAfter.status === 'active',
171
+ `status=${attackerRowAfter.status} (expected active)`,
172
+ )
173
+
174
+ /* ===== B: positive control — an admin can still call it ===== */
175
+
176
+ record(
177
+ 'B1: an admin can still process a pending row',
178
+ baseline.status === 200 && baseline.body?.unimplemented === true,
179
+ `status=${baseline.status} body=${JSON.stringify(baseline.body)}`,
180
+ )
181
+
182
+ /* ===== C: the replay gate — the same row must not fire twice ===== */
183
+
184
+ const replay = await post(adminToken, baselineRow.id)
185
+ record(
186
+ 'C1: processing the same row twice is rejected (no replay)',
187
+ replay.status === 409,
188
+ `second call status=${replay.status} body=${JSON.stringify(replay.body)} (expected 409)`,
189
+ )
190
+
191
+ const baselineRowAfter = await sdk.api.automated_actions.getOne(baselineRow.id)
192
+ record(
193
+ 'C2: a processed row is marked finished so neither a replay nor the worker re-runs it',
194
+ baselineRowAfter.status === 'finished',
195
+ `status=${baselineRowAfter.status} (expected finished)`,
196
+ )
197
+
198
+ /* ===== report: print full table first, then assert (harness exits on first failure) ===== */
199
+ console.log('\n F-0192 check summary:')
200
+ for (const c of checks) {
201
+ console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
202
+ }
203
+ console.log('')
204
+
205
+ for (const c of checks) {
206
+ assert(c.pass, `${c.label} — ${c.detail}`, c.label)
207
+ }
208
+ } finally {
209
+ // Reverse order. The automated_actions go first: they hang off enduserId with
210
+ // onDependencyDelete 'delete', so deleting the enduser would take them with it.
211
+ for (const id of automatedActionIds) { try { await sdk.api.automated_actions.deleteOne(id) } catch {} }
212
+ if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
213
+ if (attackerId) { try { await sdk.api.users.deleteOne(attackerId) } catch {} }
214
+ if (roleId) { try { await sdk.api.role_based_access_permissions.deleteOne(roleId) } catch {} }
215
+ }
216
+ }
217
+
218
+ // Allow running this test file independently
219
+ if (require.main === module) {
220
+ console.log(`🌐 Using API URL: ${host}`)
221
+ const sdk = new Session({ host })
222
+ const sdkNonAdmin = new Session({ host })
223
+
224
+ const runTests = async () => {
225
+ await setup_tests(sdk, sdkNonAdmin)
226
+ await automated_actions_process_gate_tests({ sdk, sdkNonAdmin })
227
+ }
228
+
229
+ runTests()
230
+ .then(() => {
231
+ console.log("✅ F-0192 automated_actions.process gate test suite completed successfully")
232
+ process.exit(0)
233
+ })
234
+ .catch((error) => {
235
+ console.error("❌ F-0192 automated_actions.process gate test suite failed:", error)
236
+ process.exit(1)
237
+ })
238
+ }