@tellescope/sdk 1.251.0 → 1.252.1

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 (131) hide show
  1. package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.js +139 -0
  4. package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js +337 -0
  8. package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js +287 -0
  12. package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/integrations_redacted.test.d.ts.map +1 -1
  14. package/lib/cjs/tests/api_tests/integrations_redacted.test.js +30 -20
  15. package/lib/cjs/tests/api_tests/integrations_redacted.test.js.map +1 -1
  16. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -1
  17. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +234 -198
  18. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -1
  19. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
  20. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
  21. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +349 -0
  22. package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
  24. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
  25. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +247 -0
  26. package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
  27. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
  28. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
  29. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +278 -0
  30. package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
  31. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
  32. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
  33. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +201 -0
  34. package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
  36. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
  37. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js +148 -0
  38. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
  39. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
  40. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
  41. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js +88 -0
  42. package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
  43. package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts +32 -0
  44. package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts.map +1 -0
  45. package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js +237 -0
  46. package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js.map +1 -0
  47. package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts +38 -0
  48. package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts.map +1 -0
  49. package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js +222 -0
  50. package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js.map +1 -0
  51. package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts +6 -0
  52. package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts.map +1 -0
  53. package/lib/cjs/tests/api_tests/user_portal_settings.test.js +301 -0
  54. package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -0
  55. package/lib/cjs/tests/tests.d.ts.map +1 -1
  56. package/lib/cjs/tests/tests.js +198 -151
  57. package/lib/cjs/tests/tests.js.map +1 -1
  58. package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.d.ts +6 -0
  59. package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.d.ts.map +1 -0
  60. package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.js +135 -0
  61. package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.js.map +1 -0
  62. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
  63. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
  64. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js +333 -0
  65. package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
  66. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
  67. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
  68. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js +280 -0
  69. package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
  70. package/lib/esm/tests/api_tests/integrations_redacted.test.d.ts.map +1 -1
  71. package/lib/esm/tests/api_tests/integrations_redacted.test.js +30 -20
  72. package/lib/esm/tests/api_tests/integrations_redacted.test.js.map +1 -1
  73. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -1
  74. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +235 -199
  75. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -1
  76. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
  77. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +345 -0
  79. package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
  80. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
  81. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
  82. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +243 -0
  83. package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
  84. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
  85. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +271 -0
  87. package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
  88. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
  89. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
  90. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +194 -0
  91. package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
  92. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
  93. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
  94. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js +144 -0
  95. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
  96. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
  97. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
  98. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js +84 -0
  99. package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
  100. package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts +32 -0
  101. package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts.map +1 -0
  102. package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js +233 -0
  103. package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js.map +1 -0
  104. package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts +38 -0
  105. package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts.map +1 -0
  106. package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js +218 -0
  107. package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js.map +1 -0
  108. package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts +6 -0
  109. package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts.map +1 -0
  110. package/lib/esm/tests/api_tests/user_portal_settings.test.js +297 -0
  111. package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -0
  112. package/lib/esm/tests/tests.d.ts.map +1 -1
  113. package/lib/esm/tests/tests.js +198 -151
  114. package/lib/esm/tests/tests.js.map +1 -1
  115. package/lib/tsconfig.tsbuildinfo +1 -1
  116. package/package.json +10 -10
  117. package/src/tests/api_tests/calendar_event_webhook_template.test.ts +204 -0
  118. package/src/tests/api_tests/enduser_login_rate_limits.test.ts +178 -0
  119. package/src/tests/api_tests/integrations_redacted.test.ts +8 -0
  120. package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +113 -88
  121. package/src/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.ts +236 -0
  122. package/src/tests/api_tests/security/F-0005-ai-conversations-rbac.test.ts +154 -0
  123. package/src/tests/api_tests/security/F-0007-invite-user-enumeration.test.ts +198 -0
  124. package/src/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.ts +130 -0
  125. package/src/tests/api_tests/security/F-0013-sanitize-user-html.test.ts +109 -0
  126. package/src/tests/api_tests/security/F-0016-prototype-pollution.test.ts +50 -0
  127. package/src/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.ts +161 -0
  128. package/src/tests/api_tests/security/F-0076-self-admin-role-assignment.test.ts +165 -0
  129. package/src/tests/api_tests/user_portal_settings.test.ts +217 -0
  130. package/src/tests/tests.ts +25 -2
  131. package/test_generated.pdf +0 -0
@@ -0,0 +1,236 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ wait,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../../setup"
10
+ import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+ const [nonAdminEmail, nonAdminPassword] = [process.env.NON_ADMIN_EMAIL, process.env.NON_ADMIN_PASSWORD]
14
+
15
+ const FULL_ACCESS = { create: 'All' as const, read: 'All' as const, update: 'All' as const, delete: 'All' as const }
16
+
17
+ // Schema fields tagged `redactions: ['all']` that must never appear in
18
+ // `/v1/data-sync` results. See packages/public/schema/src/schema.ts.
19
+ const REDACTABLE_FIELDS_BY_MODEL: Record<string, string[]> = {
20
+ users: ['hashedPass', 'hashedInviteCode'],
21
+ endusers: ['hashedPassword'],
22
+ }
23
+
24
+ type Violation = { modelName: string, recordId: string, leakedField: string }
25
+
26
+ const collectViolations = (results: { modelName: string, recordId: string, data: string }[]): Violation[] => {
27
+ const violations: Violation[] = []
28
+ for (const record of results) {
29
+ if (!record.data || record.data === 'deleted') continue
30
+ const fields = REDACTABLE_FIELDS_BY_MODEL[record.modelName]
31
+ if (!fields) continue
32
+ let parsed: any
33
+ try { parsed = JSON.parse(record.data) } catch { continue }
34
+ for (const f of fields) {
35
+ if (f in parsed && parsed[f] !== undefined && parsed[f] !== null && parsed[f] !== '') {
36
+ violations.push({ modelName: record.modelName, recordId: record.recordId, leakedField: f })
37
+ }
38
+ }
39
+ }
40
+ return violations
41
+ }
42
+
43
+ /**
44
+ * Regression test for F-0001 (security-audit/findings/F-0001-data-sync-bypasses-applyRedactions.md).
45
+ *
46
+ * The /v1/data-sync handler must apply the central applyRedactions() pipeline to
47
+ * every non-deleted record. The original bug: redactions were gated behind
48
+ * `if (session.fieldRedactions && session.fieldRedactions[record.modelName])`
49
+ * which meant any session without role-based field redactions (including all
50
+ * admins) received raw records — leaking schema-level `redactions: ['all']`
51
+ * fields (hashedPass, hashedPassword, hashedInviteCode).
52
+ *
53
+ * This test:
54
+ * 1. Configures a non-admin user with broad read access on users + endusers
55
+ * and NO fieldRedactions — the realistic "regular user with read access"
56
+ * condition that triggers the bypass.
57
+ * 2. Creates an enduser with a password to populate the sync stream.
58
+ * 3. Calls /v1/data-sync as the non-admin.
59
+ * 4. Asserts no returned record contains hashedPass / hashedPassword /
60
+ * hashedInviteCode.
61
+ *
62
+ * Pre-fix: assertion fails with leaked records.
63
+ * Post-fix: assertion passes.
64
+ */
65
+ export const data_sync_redaction_bypass_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
66
+ log_header("F-0001: /v1/data-sync field-redaction bypass regression")
67
+
68
+ const roleName = `f0001-data-sync-bypass-${Date.now()}`
69
+ let testEnduserId: string | undefined
70
+ let rbapId: string | undefined
71
+ const originalRoles = sdkNonAdmin.userInfo.roles
72
+
73
+ try {
74
+ // 1. Create a role with broad read access but NO fieldRedactions.
75
+ // This is the realistic "regular user with read access" condition
76
+ // that triggers the bypass in the pre-fix handler.
77
+ const rbap = await sdk.api.role_based_access_permissions.createOne({
78
+ role: roleName,
79
+ permissions: {
80
+ ...PROVIDER_PERMISSIONS,
81
+ users: FULL_ACCESS,
82
+ endusers: FULL_ACCESS,
83
+ },
84
+ // intentionally NO fieldRedactions — this is the exploit condition.
85
+ })
86
+ rbapId = rbap.id
87
+
88
+ // 2. Assign role to the non-admin user and re-authenticate so the new
89
+ // session reflects the role's permissions.
90
+ await sdk.api.users.updateOne(
91
+ sdkNonAdmin.userInfo.id,
92
+ { roles: [roleName] },
93
+ { replaceObjectFields: true },
94
+ )
95
+ await wait(undefined, 1500)
96
+ await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!)
97
+
98
+ // 3. Create a test enduser and set a password — this populates
99
+ // `hashedPassword` on the enduser record and writes a data_sync_records
100
+ // row for it.
101
+ const testEnduser = await sdk.api.endusers.createOne({
102
+ fname: 'F0001Target',
103
+ lname: 'Patient',
104
+ email: `f0001-target-${Date.now()}@example.com`,
105
+ })
106
+ testEnduserId = testEnduser.id
107
+ await sdk.api.endusers.set_password({ id: testEnduser.id, password: 'F0001TestPassword!123' })
108
+
109
+ // The non-admin user's own `hashedPass` is set from login and refreshed
110
+ // on every write to their user record (e.g., the role-assignment update
111
+ // above). No extra setup needed for users.hashedPass to be in the stream.
112
+
113
+ await wait(undefined, 500)
114
+
115
+ // 4. As the non-admin, call /v1/data-sync from epoch zero to capture all
116
+ // sync records the role can see.
117
+ const sync = await sdkNonAdmin.sync({ from: new Date(0) })
118
+
119
+ // 5. Walk every record. Fail if any contains a `redactions: ['all']` field.
120
+ const violations = collectViolations(sync.results)
121
+
122
+ // Belt-and-suspenders: at least one user record SHOULD be in the stream
123
+ // (the non-admin's own record). If the stream is empty, the assertion below
124
+ // would pass vacuously — guard against that.
125
+ const userRecordsInStream = sync.results.filter(r => r.modelName === 'users').length
126
+ await async_test(
127
+ "F-0001 guard: /v1/data-sync sync stream contains at least one user record",
128
+ async () => ({ userRecords: userRecordsInStream, totalRecords: sync.results.length }),
129
+ { onResult: r => r.userRecords >= 1 },
130
+ )
131
+
132
+ await async_test(
133
+ "F-0001: /v1/data-sync must NOT return hashedPass / hashedPassword / hashedInviteCode (see security-audit/findings/F-0001)",
134
+ async () => ({
135
+ violationCount: violations.length,
136
+ violations: violations.slice(0, 10),
137
+ affectedModels: Array.from(new Set(violations.map(v => v.modelName))),
138
+ affectedFields: Array.from(new Set(violations.map(v => v.leakedField))),
139
+ }),
140
+ { onResult: r => r.violationCount === 0 },
141
+ )
142
+
143
+ // ========================================================================
144
+ // Additional coverage for applyRedactions code paths reachable via /v1/data-sync.
145
+ // Each of these is a distinct branch in applyRedactions (routing.ts:1165-1238)
146
+ // and could regress independently of the F-0001 fix.
147
+ // ========================================================================
148
+
149
+ // Case A: schema-level `redactions: ['all']` must apply to ADMIN sessions too.
150
+ // Admins have no session.fieldRedactions, but `redactions: ['all']` is universal.
151
+ // Pre-fix: admin saw hashedPass via data-sync because applyRedactions was skipped entirely.
152
+ // Post-fix: applyRedactions always runs and `redactions: ['all']` strips for everyone.
153
+ const adminSync = await sdk.sync({ from: new Date(0) })
154
+ const adminViolations = collectViolations(adminSync.results)
155
+ await async_test(
156
+ "F-0001 coverage A: admin /v1/data-sync also strips redactions:['all'] fields (hashedPass etc.)",
157
+ async () => ({
158
+ violationCount: adminViolations.length,
159
+ violations: adminViolations.slice(0, 10),
160
+ }),
161
+ { onResult: r => r.violationCount === 0 },
162
+ )
163
+
164
+ // Case B: `linkedAccountAccess` on users must be stripped when the caller is NOT
165
+ // the record's owner. This is a separate branch in applyRedactions (routing.ts:1220-1225)
166
+ // and protects against cross-user enumeration of who-requested-access-to-whom.
167
+ // The non-admin user reads other user records via data-sync; if any of those
168
+ // records have linkedAccountAccess set, it must be stripped on read.
169
+ const otherUsersInStream = sync.results.filter(
170
+ r => r.modelName === 'users' && r.recordId !== sdkNonAdmin.userInfo.id
171
+ )
172
+ const linkedAccountLeaks: { recordId: string }[] = []
173
+ for (const record of otherUsersInStream) {
174
+ if (!record.data || record.data === 'deleted') continue
175
+ try {
176
+ const parsed = JSON.parse(record.data)
177
+ if ('linkedAccountAccess' in parsed && parsed.linkedAccountAccess !== undefined) {
178
+ linkedAccountLeaks.push({ recordId: record.recordId })
179
+ }
180
+ } catch {}
181
+ }
182
+ await async_test(
183
+ "F-0001 coverage B: /v1/data-sync strips linkedAccountAccess from other users' records",
184
+ async () => ({
185
+ otherUserRecords: otherUsersInStream.length,
186
+ leakCount: linkedAccountLeaks.length,
187
+ leaks: linkedAccountLeaks.slice(0, 5),
188
+ }),
189
+ { onResult: r => r.leakCount === 0 },
190
+ )
191
+ } finally {
192
+ // Cleanup: restore non-admin's original roles, delete role and test enduser.
193
+ try {
194
+ await sdk.api.users.updateOne(
195
+ sdkNonAdmin.userInfo.id,
196
+ { roles: originalRoles ?? [] },
197
+ { replaceObjectFields: true },
198
+ )
199
+ } catch {}
200
+ if (rbapId) {
201
+ try { await sdk.api.role_based_access_permissions.deleteOne(rbapId) } catch {}
202
+ }
203
+ if (testEnduserId) {
204
+ try { await sdk.api.endusers.deleteOne(testEnduserId) } catch {}
205
+ }
206
+ // Re-authenticate the non-admin to drop the exploit role from their JWT
207
+ // before subsequent tests run.
208
+ // Role restore above re-triggers deauthenticate_user; wait > 1s so the freshly minted
209
+ // token's (second-floored) iat lands after the deauth timestamp and isn't permanently
210
+ // rejected by is_logged_in's iat-slack check. Matches the in-test re-auth above.
211
+ await wait(undefined, 1500)
212
+ try { await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!) } catch {}
213
+ }
214
+ }
215
+
216
+ // Allow running this test file independently
217
+ if (require.main === module) {
218
+ console.log(`🌐 Using API URL: ${host}`)
219
+ const sdk = new Session({ host })
220
+ const sdkNonAdmin = new Session({ host })
221
+
222
+ const runTests = async () => {
223
+ await setup_tests(sdk, sdkNonAdmin)
224
+ await data_sync_redaction_bypass_tests({ sdk, sdkNonAdmin })
225
+ }
226
+
227
+ runTests()
228
+ .then(() => {
229
+ console.log("✅ F-0001 data-sync redaction-bypass test suite completed successfully")
230
+ process.exit(0)
231
+ })
232
+ .catch((error) => {
233
+ console.error("❌ F-0001 data-sync redaction-bypass test suite failed:", error)
234
+ process.exit(1)
235
+ })
236
+ }
@@ -0,0 +1,154 @@
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-0005 (security-audit/findings/F-0005-ai-conversations-bypass-rbac.md).
18
+ *
19
+ * Both `ai_conversations.send_message` and `ai_conversations.generate_ai_decision` are
20
+ * registered with `noAccessPermissions: true` ([api.ts:22699, 22721](packages/private/api/api/v1/api.ts)).
21
+ * Their only access gate is `if (req.session.type === 'enduser') throw 403`. They must ALSO
22
+ * check `session.access?.ai_conversations?.<action>` (and `session.access?.endusers?.read`
23
+ * for generate_ai_decision) — the standard pattern used 16 lines earlier in the same file
24
+ * at api.ts:22680 for the background_errors handler.
25
+ *
26
+ * This test:
27
+ * 1. Creates a role with explicit NO_ACCESS for ai_conversations (and endusers).
28
+ * 2. Assigns the role to the non-admin user.
29
+ * 3. Calls each endpoint as the non-admin.
30
+ * 4. Asserts each endpoint returns a 403-equivalent error (not 200).
31
+ *
32
+ * Pre-fix:
33
+ * - send_message: 200 (or some downstream error from Bedrock) — NOT 403. Test fails.
34
+ * - generate_ai_decision: 200 with `{}` (handler responds early before access check). Test fails.
35
+ *
36
+ * Post-fix: both endpoints throw 403 before any work happens. Test passes.
37
+ */
38
+ export const ai_conversations_rbac_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
39
+ log_header("F-0005: ai_conversations RBAC bypass regression")
40
+
41
+ const roleName = `f0005-ai-conversations-no-access-${Date.now()}`
42
+ let rbapId: string | undefined
43
+ const originalRoles = sdkNonAdmin.userInfo.roles
44
+
45
+ try {
46
+ // 1. Create a role that explicitly denies ai_conversations access AND endusers access.
47
+ // This is the realistic configuration the bypass affects: a tenant operator
48
+ // deliberately restricts a role from reading AI conversations / enduser PHI.
49
+ const rbap = await sdk.api.role_based_access_permissions.createOne({
50
+ role: roleName,
51
+ permissions: {
52
+ ...PROVIDER_PERMISSIONS,
53
+ ai_conversations: { create: null, read: null, update: null, delete: null },
54
+ endusers: { create: null, read: null, update: null, delete: null },
55
+ },
56
+ })
57
+ rbapId = rbap.id
58
+
59
+ // 2. Assign the role to the non-admin user and re-authenticate so the new
60
+ // session reflects the role's denied permissions.
61
+ await sdk.api.users.updateOne(
62
+ sdkNonAdmin.userInfo.id,
63
+ { roles: [roleName] },
64
+ { replaceObjectFields: true },
65
+ )
66
+ await wait(undefined, 1500)
67
+ await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!)
68
+
69
+ // 3a. send_message must throw 403 (or equivalent access error). Pre-fix: succeeds (or
70
+ // fails downstream in Bedrock without 403). Post-fix: throws before any work.
71
+ await async_test(
72
+ "F-0005: ai_conversations.send_message must throw 403 when role denies ai_conversations",
73
+ () => sdkNonAdmin.api.ai_conversations.send_message({
74
+ message: 'F-0005 regression test',
75
+ type: 'Test',
76
+ } as any),
77
+ {
78
+ shouldError: true,
79
+ onError: (e: any) => {
80
+ // Accept any 4xx access-denial response — handler may use 403 (recommended)
81
+ // or 400 with "access" / "permission" in the message.
82
+ const msg = (e?.message ?? '').toLowerCase()
83
+ const status = e?.status ?? e?.code
84
+ return status === 403 || status === 401
85
+ || msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')
86
+ },
87
+ },
88
+ )
89
+
90
+ // 3b. generate_ai_decision must throw 403 BEFORE the early res.json({}) response.
91
+ // Pre-fix: handler responds 200 with {} immediately and processes in background.
92
+ // Post-fix: handler throws 403 before res.json({}).
93
+ await async_test(
94
+ "F-0005: ai_conversations.generate_ai_decision must throw 403 when role denies endusers/ai_conversations",
95
+ () => sdkNonAdmin.api.ai_conversations.generate_ai_decision({
96
+ enduserId: new ObjectId().toHexString(), // fake id — access check should fire first
97
+ automationStepId: new ObjectId().toHexString(), // fake id — access check should fire first
98
+ outcomes: ['yes', 'no'],
99
+ prompt: 'F-0005 regression test',
100
+ sources: [{ type: 'SMS', limit: 1 }], // non-empty so the validator passes; access check then fires
101
+ } as any),
102
+ {
103
+ shouldError: true,
104
+ onError: (e: any) => {
105
+ const msg = (e?.message ?? '').toLowerCase()
106
+ const status = e?.status ?? e?.code
107
+ return status === 403 || status === 401
108
+ || msg.includes('access') || msg.includes('permission') || msg.includes('forbidden')
109
+ },
110
+ },
111
+ )
112
+ } finally {
113
+ // Cleanup: restore original roles, delete the test role.
114
+ try {
115
+ await sdk.api.users.updateOne(
116
+ sdkNonAdmin.userInfo.id,
117
+ { roles: originalRoles ?? [] },
118
+ { replaceObjectFields: true },
119
+ )
120
+ } catch {}
121
+ if (rbapId) {
122
+ try { await sdk.api.role_based_access_permissions.deleteOne(rbapId) } catch {}
123
+ }
124
+ // Re-authenticate the non-admin to drop the no-access role from their JWT
125
+ // before subsequent tests run.
126
+ // Role restore above re-triggers deauthenticate_user; wait > 1s so the freshly minted
127
+ // token's (second-floored) iat lands after the deauth timestamp and isn't permanently
128
+ // rejected by is_logged_in's iat-slack check. Matches the in-test re-auth above.
129
+ await wait(undefined, 1500)
130
+ try { await sdkNonAdmin.authenticate(nonAdminEmail!, nonAdminPassword!) } catch {}
131
+ }
132
+ }
133
+
134
+ // Allow running this test file independently
135
+ if (require.main === module) {
136
+ console.log(`🌐 Using API URL: ${host}`)
137
+ const sdk = new Session({ host })
138
+ const sdkNonAdmin = new Session({ host })
139
+
140
+ const runTests = async () => {
141
+ await setup_tests(sdk, sdkNonAdmin)
142
+ await ai_conversations_rbac_tests({ sdk, sdkNonAdmin })
143
+ }
144
+
145
+ runTests()
146
+ .then(() => {
147
+ console.log("✅ F-0005 ai_conversations RBAC test suite completed successfully")
148
+ process.exit(0)
149
+ })
150
+ .catch((error) => {
151
+ console.error("❌ F-0005 ai_conversations RBAC test suite failed:", error)
152
+ process.exit(1)
153
+ })
154
+ }
@@ -0,0 +1,198 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ const CROSS_ORG_API_KEY = process.env.CROSS_ORG_API_KEY
15
+ const CROSS_ORG_TARGET_BUSINESS_ID = process.env.CROSS_ORG_TARGET_BUSINESS_ID
16
+
17
+ const post = async (path: string, body: any, headers: Record<string, string> = {}) => {
18
+ try {
19
+ const res = await axios.post(`${host}${path}`, body, {
20
+ validateStatus: () => true,
21
+ headers,
22
+ })
23
+ return { status: res.status, data: res.data }
24
+ } catch (err: any) {
25
+ return { status: err?.response?.status, data: err?.response?.data }
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Regression test for F-0007 (security-audit/findings/F-0007-invite-user-cross-tenant-email-enumeration.md).
31
+ *
32
+ * `users.invite_user` previously used `buildAllQueries({ unrestricted: true, organizationIds: [] }).users.findOne({ email })`
33
+ * to enforce platform-wide email uniqueness and threw the distinctive `"A user with this email already exists"`
34
+ * error on duplicate — regardless of which tenant the existing user was in. Any authenticated user could
35
+ * therefore probe whether email X is registered to any tenant on the platform.
36
+ *
37
+ * **Tests only the negative case** — never drives a successful invite (which would create a real user
38
+ * record and send a real transactional email). All assertions use either:
39
+ * - An email that already exists in the test tenant (the admin's own email), so each call short-circuits
40
+ * at the same-tenant duplicate check or rate-limit check before any invite work happens, OR
41
+ * - The cross-org infrastructure (CROSS_ORG_API_KEY env var) targeting an email that exists in a different
42
+ * tenant — verifies the post-fix response does NOT distinguish "exists elsewhere" from a generic outcome.
43
+ *
44
+ * Assertions:
45
+ * 1. Rate-limit defense-in-depth: rapid same-tenant duplicate requests trip 429 within ~12 attempts.
46
+ * 2. Same-tenant duplicate: returns the same `"already exists"` error pre/post-fix (this branch is
47
+ * unchanged by the fix; asserted for regression-safety).
48
+ * 3. Cross-tenant duplicate (env-gated): post-fix response shape does NOT contain the `"already exists"`
49
+ * string and matches the silent-no-op shape `{ created: { id: ... } }`. Skipped when CROSS_ORG_*
50
+ * env vars are not set, mirroring cross_org_api_key.test.ts convention.
51
+ */
52
+ export const invite_user_enumeration_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
53
+ log_header("F-0007: users.invite_user cross-tenant enumeration regression")
54
+
55
+ // Reset state so prior tests' rate-limit accounting doesn't leak in.
56
+ await sdk.reset_db()
57
+
58
+ const organizationId = sdk.userInfo.organizationIds?.[0] ?? sdk.userInfo.businessId
59
+ // Use the test admin's own email — guaranteed to exist in the test tenant, so every invite
60
+ // request short-circuits at the same-tenant duplicate check (or earlier at the rate-limit
61
+ // check). No real users get created, no emails get sent.
62
+ const sameTenantExistingEmail = process.env.TEST_EMAIL!
63
+
64
+ // ====================================================================
65
+ // Assertion 1: rate-limit defense-in-depth
66
+ // Fire 15 requests with the admin's own email. Post-fix: rate limit
67
+ // fires before the same-tenant duplicate check on call 11+. Pre-fix:
68
+ // every call returns 400 "already exists" forever.
69
+ // ====================================================================
70
+ let rateLimitedAt = -1
71
+ for (let i = 0; i < 15; i++) {
72
+ const r = await post(
73
+ '/v1/invite-user-to-organization',
74
+ {
75
+ email: sameTenantExistingEmail,
76
+ fname: 'F0007', lname: 'RateLimit',
77
+ organizationId,
78
+ },
79
+ { Authorization: `Bearer ${sdk.authToken}` },
80
+ )
81
+ if (r.status === 429) {
82
+ rateLimitedAt = i
83
+ break
84
+ }
85
+ }
86
+
87
+ await async_test(
88
+ "F-0007: invite_user rate-limits within ~12 rapid requests (defense-in-depth; no invites sent)",
89
+ async () => ({ rateLimitedAt }),
90
+ { onResult: r => r.rateLimitedAt >= 0 && r.rateLimitedAt <= 12 },
91
+ )
92
+
93
+ // ====================================================================
94
+ // Assertion 2: same-tenant duplicate returns the descriptive error
95
+ // (unchanged by the fix; regression guard so a future change to the
96
+ // duplicate-detection path doesn't accidentally suppress same-tenant
97
+ // errors that operators rely on).
98
+ // ====================================================================
99
+ // Let rate limit decay so this single call isn't blocked.
100
+ await wait(undefined, 5000)
101
+ await sdk.reset_db()
102
+
103
+ const sameTenantRes = await post(
104
+ '/v1/invite-user-to-organization',
105
+ {
106
+ email: sameTenantExistingEmail,
107
+ fname: 'F0007', lname: 'SameTenant',
108
+ organizationId,
109
+ },
110
+ { Authorization: `Bearer ${sdk.authToken}` },
111
+ )
112
+
113
+ await async_test(
114
+ "F-0007: invite_user same-tenant duplicate returns 400 'already exists' (unchanged)",
115
+ async () => ({
116
+ status: sameTenantRes.status,
117
+ message: sameTenantRes.data?.message ?? '',
118
+ }),
119
+ { onResult: r => r.status === 400 && r.message.toLowerCase().includes('already exists') },
120
+ )
121
+
122
+ // ====================================================================
123
+ // Assertion 3: cross-tenant duplicate must NOT reveal existence
124
+ // Env-gated; skipped when cross-org infra not configured.
125
+ // ====================================================================
126
+ if (!(CROSS_ORG_API_KEY && CROSS_ORG_TARGET_BUSINESS_ID)) {
127
+ console.log(" [F-0007] Skipping cross-tenant silent no-op assertion — CROSS_ORG_* env vars not set")
128
+ return
129
+ }
130
+
131
+ // The target email is the admin's email in the home (test) tenant. We then attempt to invite
132
+ // that same email FROM a session belonging to the cross-org target tenant. Post-fix, the
133
+ // response should NOT contain "already exists" — it must be indistinguishable from a
134
+ // successful invite OR a generic non-revealing response. Pre-fix, the response is the
135
+ // distinctive "already exists" error revealing cross-tenant existence.
136
+ const sdkCrossOrg = new Session({
137
+ host,
138
+ apiKey: CROSS_ORG_API_KEY,
139
+ headers: { 'x-tellescope-organization': CROSS_ORG_TARGET_BUSINESS_ID },
140
+ })
141
+
142
+ // Resolve an organizationId in the target business. Falls back to the businessId itself.
143
+ let targetOrgId = CROSS_ORG_TARGET_BUSINESS_ID
144
+ try {
145
+ const orgs = await sdkCrossOrg.api.organizations.getSome({ limit: 1 })
146
+ if (orgs?.[0]?.id) targetOrgId = orgs[0].id
147
+ } catch {}
148
+
149
+ const crossRes = await post(
150
+ '/v1/invite-user-to-organization',
151
+ {
152
+ email: sameTenantExistingEmail,
153
+ fname: 'F0007', lname: 'CrossTenantProbe',
154
+ organizationId: targetOrgId,
155
+ },
156
+ {
157
+ Authorization: `API_KEY ${CROSS_ORG_API_KEY}`,
158
+ 'x-tellescope-organization': CROSS_ORG_TARGET_BUSINESS_ID,
159
+ },
160
+ )
161
+
162
+ await async_test(
163
+ "F-0007: cross-tenant invite of existing email must NOT return 'already exists' (no enumeration)",
164
+ async () => ({
165
+ status: crossRes.status,
166
+ message: crossRes.data?.message ?? '',
167
+ hasCreatedShape: !!crossRes.data?.created?.id,
168
+ }),
169
+ {
170
+ onResult: r =>
171
+ // The response MUST NOT contain the "already exists" string regardless of status.
172
+ // Acceptable post-fix shapes: 200 with `{ created: { id: ... } }` (silent no-op), or
173
+ // 200 with a generic ack. Rate-limit 429 also OK if it slipped through to here.
174
+ !r.message.toLowerCase().includes('already exists'),
175
+ },
176
+ )
177
+ }
178
+
179
+ if (require.main === module) {
180
+ console.log(`🌐 Using API URL: ${host}`)
181
+ const sdk = new Session({ host })
182
+ const sdkNonAdmin = new Session({ host })
183
+
184
+ const runTests = async () => {
185
+ await setup_tests(sdk, sdkNonAdmin)
186
+ await invite_user_enumeration_tests({ sdk, sdkNonAdmin })
187
+ }
188
+
189
+ runTests()
190
+ .then(() => {
191
+ console.log("✅ F-0007 invite_user enumeration test suite completed successfully")
192
+ process.exit(0)
193
+ })
194
+ .catch((error) => {
195
+ console.error("❌ F-0007 invite_user enumeration test suite failed:", error)
196
+ process.exit(1)
197
+ })
198
+ }