@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,130 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { ObjectId } from 'bson'
5
+ import { Session } from "../../../sdk"
6
+ import {
7
+ async_test,
8
+ log_header,
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-0008 (security-audit/findings/F-0008-handle-incoming-communication-cross-tenant-enduser-lookup.md).
31
+ *
32
+ * `journeys.handle_incoming_communication` previously used `buildAllQueries({ unrestricted: true, organizationIds: [] }).endusers.findById(enduserId)`,
33
+ * permitting cross-tenant lookup of any enduser by id. The handler then called `handleIncomingCommunication(...)`
34
+ * against the matched enduser, triggering journey progression and automated actions on someone else's tenant.
35
+ *
36
+ * The fix switches to the standard tenant-scoped `DB.endusers.findById(enduserId)` wrapper, which automatically
37
+ * filters by `req.session.businessId`. Cross-tenant lookups now return null → handler returns 404 → no side effect.
38
+ *
39
+ * Note: the same-tenant happy path is already covered by the existing test at
40
+ * `packages/public/sdk/src/tests/tests.ts:7588` ("handle_incoming_communication test for other enduser") — that
41
+ * test creates endusers in the test tenant, sets up journeys, calls handle_incoming_communication, and asserts
42
+ * journey-step cancellation. This file covers the negative cases only.
43
+ *
44
+ * **Negative-only by design**: the test never drives `handleIncomingCommunication` against a cross-tenant
45
+ * enduser — the post-fix code returns 404 before any side effects fire, and the assertion confirms that.
46
+ */
47
+ export const handle_incoming_communication_cross_tenant_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
48
+ log_header("F-0008: handle_incoming_communication cross-tenant rejection")
49
+
50
+ // ====================================================================
51
+ // Assertion 1: nonexistent enduserId returns 404 (baseline; regression
52
+ // guard for the not-found path that any cross-tenant call now lands in).
53
+ // ====================================================================
54
+ const nonexistentId = new ObjectId().toHexString()
55
+ const nonexistentRes = await post(
56
+ '/v1/journeys/handle-incoming-communication',
57
+ { enduserId: nonexistentId },
58
+ { Authorization: `Bearer ${sdk.authToken}` },
59
+ )
60
+ await async_test(
61
+ "F-0008: handle_incoming_communication with nonexistent enduserId returns 404",
62
+ async () => ({ status: nonexistentRes.status }),
63
+ { onResult: r => r.status === 404 },
64
+ )
65
+
66
+ // ====================================================================
67
+ // Assertion 2: cross-tenant enduserId returns 404 (the actual F-0008 fix).
68
+ // Env-gated; skipped when cross-org infra isn't configured.
69
+ // Safe to run post-fix because the tenant-scoped DB returns null for
70
+ // cross-tenant lookups — no handleIncomingCommunication side effect fires.
71
+ // ====================================================================
72
+ if (!(CROSS_ORG_API_KEY && CROSS_ORG_TARGET_BUSINESS_ID)) {
73
+ console.log(" [F-0008] Skipping cross-tenant rejection assertion — CROSS_ORG_* env vars not set")
74
+ return
75
+ }
76
+
77
+ const sdkCrossOrg = new Session({
78
+ host,
79
+ apiKey: CROSS_ORG_API_KEY,
80
+ headers: { 'x-tellescope-organization': CROSS_ORG_TARGET_BUSINESS_ID },
81
+ })
82
+
83
+ // Create a sentinel enduser in the cross-org tenant. Use a clearly-test email
84
+ // so any accidental side-effect routing is obvious in logs.
85
+ const ts = Date.now()
86
+ const crossEnduser = await sdkCrossOrg.api.endusers.createOne({
87
+ fname: 'F0008CrossTenant', lname: 'Sentinel',
88
+ email: `f0008-cross-${ts}@tellescope.com`,
89
+ } as any)
90
+
91
+ try {
92
+ const crossRes = await post(
93
+ '/v1/journeys/handle-incoming-communication',
94
+ { enduserId: crossEnduser.id },
95
+ { Authorization: `Bearer ${sdk.authToken}` },
96
+ )
97
+
98
+ await async_test(
99
+ "F-0008: handle_incoming_communication with cross-tenant enduserId returns 404 (no side effect)",
100
+ async () => ({
101
+ status: crossRes.status,
102
+ message: crossRes.data?.message ?? null,
103
+ }),
104
+ { onResult: r => r.status === 404 },
105
+ )
106
+ } finally {
107
+ try { await sdkCrossOrg.api.endusers.deleteOne(crossEnduser.id) } catch {}
108
+ }
109
+ }
110
+
111
+ if (require.main === module) {
112
+ console.log(`🌐 Using API URL: ${host}`)
113
+ const sdk = new Session({ host })
114
+ const sdkNonAdmin = new Session({ host })
115
+
116
+ const runTests = async () => {
117
+ await setup_tests(sdk, sdkNonAdmin)
118
+ await handle_incoming_communication_cross_tenant_tests({ sdk, sdkNonAdmin })
119
+ }
120
+
121
+ runTests()
122
+ .then(() => {
123
+ console.log("✅ F-0008 handle_incoming_communication cross-tenant test suite completed successfully")
124
+ process.exit(0)
125
+ })
126
+ .catch((error) => {
127
+ console.error("❌ F-0008 handle_incoming_communication cross-tenant test suite failed:", error)
128
+ process.exit(1)
129
+ })
130
+ }
@@ -0,0 +1,109 @@
1
+ import { sanitize_user_html } from "@tellescope/utilities"
2
+
3
+ // Regression test for F-0013 / F-0014 (pattern 06 — XSS via dangerouslySetInnerHTML).
4
+ // sanitize_user_html is the canonical render-time sanitizer that replaced remove_script_tags
5
+ // at every dangerouslySetInnerHTML sink. This asserts it neutralizes XSS vectors (incl. encoded /
6
+ // whitespace / mixed-case / iframe-srcdoc bypass variants) while preserving legitimate
7
+ // customization HTML (tables, headings, lists, links, images, inline styles).
8
+ //
9
+ // Pure-function test — no Session needed. Runs as part of the main suite and standalone:
10
+ // ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js
11
+
12
+ const fail = (msg: string) => { throw new Error(msg) }
13
+
14
+ const has_no_executable_vector = (out: string) => {
15
+ const o = out.toLowerCase()
16
+ // A handler smuggled into an attribute VALUE (e.g. title="&lt;img onerror=...&gt;") is inert
17
+ // text — strip quoted values before checking for *live* on*= attributes to avoid false positives.
18
+ const withoutValues = o.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''")
19
+ return !/\son[a-z]+\s*=/.test(withoutValues) // no live on*= event-handler attribute
20
+ && !o.includes('javascript:') // dropped schemes never appear in safe output
21
+ && !o.includes('vbscript:')
22
+ && !o.includes('<script') // literal dangerous tags (encoded &lt;script is fine)
23
+ && !o.includes('<iframe')
24
+ && !o.includes('<svg')
25
+ && !o.includes('<math')
26
+ && !o.includes('<object')
27
+ && !o.includes('<embed')
28
+ && !o.includes('<form')
29
+ && !o.includes('<noscript')
30
+ && !o.includes('<template')
31
+ }
32
+
33
+ export const sanitize_user_html_xss_tests = async () => {
34
+ console.log("Running F-0013/F-0014 sanitize_user_html XSS regression tests")
35
+
36
+ const xssPayloads: [string, string][] = [
37
+ ['img onerror', `<img src=x onerror="alert(document.domain)">`],
38
+ ['svg onload', `<svg onload="alert(1)"></svg>`],
39
+ ['svg animate onbegin', `<svg><animate onbegin="alert(1)" attributeName="x" dur="1s"></svg>`],
40
+ ['details ontoggle', `<details open ontoggle="alert(1)"></details>`],
41
+ ['input onfocus autofocus', `<input autofocus onfocus="alert(1)">`],
42
+ ['body onpageshow', `<body onpageshow="alert(1)">`],
43
+ ['a javascript scheme', `<a href="javascript:alert(1)">x</a>`],
44
+ ['a javascript entity-encoded', `<a href="jav&#x09;ascript:alert(1)">x</a>`],
45
+ ['iframe javascript src', `<iframe src="javascript:alert(1)"></iframe>`],
46
+ ['iframe srcdoc nested', `<iframe srcdoc="<img src=x onerror=alert(1)>"></iframe>`],
47
+ ['script tag', `<script>alert(1)</script>`],
48
+ ['onerror newline before =', `<img src=x onerror\n="alert(1)">`],
49
+ ['onerror mixed case', `<IMG SRC=x OnErRoR="alert(1)">`],
50
+ ['marquee onstart', `<marquee onstart="alert(1)">x</marquee>`],
51
+ // mutation / namespace confusion — svg/math/noscript/template must be stripped
52
+ ['mathml mglyph style mxss', `<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>`],
53
+ ['svg foreignObject', `<svg><foreignObject><img src=x onerror=alert(1)></foreignObject></svg>`],
54
+ ['noscript context confusion', `<noscript><p title="</noscript><img src=x onerror=alert(1)>">`],
55
+ ['template content', `<template><img src=x onerror=alert(1)></template>`],
56
+ // comment / CDATA confusion
57
+ ['comment confusion', `<!--><img src=x onerror=alert(1)>-->`],
58
+ ['cdata confusion', `<![CDATA[<img src=x onerror=alert(1)>]]>`],
59
+ // markup smuggled inside an attribute value must stay inert
60
+ ['markup inside attr value', `<img src="x" alt="<script>alert(1)</script>">`],
61
+ // protocol obfuscation
62
+ ['vbscript scheme', `<a href="vbscript:msgbox(1)">x</a>`],
63
+ ['data text/html href', `<a href="data:text/html,<script>alert(1)</script>">x</a>`],
64
+ ['javascript decimal entity', `<a href="&#74;avascript:alert(1)">x</a>`],
65
+ ['javascript newline entity', `<a href="jav&#x0A;ascript:alert(1)">x</a>`],
66
+ ]
67
+ for (const [name, payload] of xssPayloads) {
68
+ const out = sanitize_user_html(payload)
69
+ if (!has_no_executable_vector(out)) fail(`XSS not neutralized [${name}] -> ${out}`)
70
+ }
71
+
72
+ // DOM clobbering: caller-controlled id/name must be stripped
73
+ const clobber = sanitize_user_html(`<a id="x" name="getElementById">link</a><img name="y">`)
74
+ if (/\b(id|name)\s*=/.test(clobber)) fail(`id/name not stripped (DOM clobbering): ${clobber}`)
75
+
76
+ // legitimate customization HTML must survive
77
+ const heading = sanitize_user_html(`<h1>Welcome</h1><h3 style="color:#333">Sub</h3>`)
78
+ if (!(heading.includes('<h1>') && heading.includes('<h3') && heading.toLowerCase().includes('color'))) fail(`headings/style stripped: ${heading}`)
79
+
80
+ const table = sanitize_user_html(`<table><thead><tr><th>H</th></tr></thead><tbody><tr><td style="padding:4px" colspan="2">cell</td></tr></tbody></table>`)
81
+ if (!(table.includes('<table') && table.includes('<td') && table.includes('colspan'))) fail(`table stripped: ${table}`)
82
+
83
+ const list = sanitize_user_html(`<ul><li>a</li></ul><ol start="3"><li>c</li></ol>`)
84
+ if (!(list.includes('<ul') && list.includes('<li') && list.includes('<ol'))) fail(`list stripped: ${list}`)
85
+
86
+ const link = sanitize_user_html(`<a href="https://example.com">link</a>`)
87
+ if (!link.includes('href="https://example.com"')) fail(`safe link stripped: ${link}`)
88
+ if (!link.toLowerCase().includes('noopener')) fail(`external link not hardened: ${link}`)
89
+
90
+ const img = sanitize_user_html(`<img src="https://cdn.example.com/a.png" alt="pic" width="200">`)
91
+ if (!(img.includes('src="https://cdn.example.com/a.png"') && img.includes('alt="pic"'))) fail(`http image stripped: ${img}`)
92
+
93
+ const dataimg = sanitize_user_html(`<img src="data:image/png;base64,iVBORw0KGgo=">`)
94
+ if (!dataimg.includes('data:image/png')) fail(`data: image stripped: ${dataimg}`)
95
+
96
+ const fmt = sanitize_user_html(`<p><strong>b</strong> <em>i</em> <span style="font-size:14px">s</span></p><blockquote>q</blockquote>`)
97
+ if (!(fmt.includes('<strong>') && fmt.includes('<span') && fmt.toLowerCase().includes('font-size'))) fail(`formatting stripped: ${fmt}`)
98
+
99
+ const mixed = sanitize_user_html(`<p>Hello <b>name</b></p><img src=x onerror="steal()">`)
100
+ if (!(mixed.includes('<b>name</b>') && !/\son[a-z]+\s*=/.test(mixed.toLowerCase()))) fail(`mixed content not handled: ${mixed}`)
101
+
102
+ console.log("✅ F-0013/F-0014 sanitize_user_html XSS regression tests passed")
103
+ }
104
+
105
+ if (require.main === module) {
106
+ sanitize_user_html_xss_tests()
107
+ .then(() => { console.log("✅ suite completed"); process.exit(0) })
108
+ .catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
109
+ }
@@ -0,0 +1,50 @@
1
+ import { add_value_for_dotted_key } from "@tellescope/utilities"
2
+
3
+ // Regression test for F-0016 (pattern 17 — prototype pollution).
4
+ // add_value_for_dotted_key must NOT write through __proto__/constructor/prototype path segments
5
+ // (which would pollute Object.prototype process-wide), while still performing legitimate dotted assignment.
6
+ //
7
+ // Pure-function test — no Session needed. Runs in the main suite and standalone:
8
+ // ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js
9
+
10
+ const fail = (msg: string) => { throw new Error(msg) }
11
+
12
+ export const prototype_pollution_tests = async () => {
13
+ console.log("Running F-0016 prototype-pollution regression tests")
14
+
15
+ // 1. __proto__ path must not pollute Object.prototype
16
+ add_value_for_dotted_key({ insurance: {} } as any, 'insurance.__proto__.__pp_a__', 'polluted')
17
+ const leakedA = ({} as any).__pp_a__
18
+ delete (Object.prototype as any).__pp_a__ // clean up regardless, so a failure here can't contaminate the rest of the suite
19
+ if (leakedA !== undefined) fail('Object.prototype polluted via __proto__ path')
20
+
21
+ // 2. constructor.prototype path must not pollute
22
+ add_value_for_dotted_key({ insurance: {} } as any, 'insurance.constructor.prototype.__pp_b__', 'polluted')
23
+ const leakedB = ({} as any).__pp_b__
24
+ delete (Object.prototype as any).__pp_b__
25
+ if (leakedB !== undefined) fail('Object.prototype polluted via constructor.prototype path')
26
+
27
+ // 3. a leading __proto__ segment must not pollute either
28
+ add_value_for_dotted_key({} as any, '__proto__.__pp_c__', 'polluted')
29
+ const leakedC = ({} as any).__pp_c__
30
+ delete (Object.prototype as any).__pp_c__
31
+ if (leakedC !== undefined) fail('Object.prototype polluted via leading __proto__ segment')
32
+
33
+ // 4. legitimate dotted assignment still works (existing intermediate objects)
34
+ const obj = { a: { b: {} } } as any
35
+ add_value_for_dotted_key(obj, 'a.b.c', 42)
36
+ if (obj.a.b.c !== 42) fail('legitimate dotted assignment broke')
37
+
38
+ // 5. single-key assignment still works
39
+ const flat = {} as any
40
+ add_value_for_dotted_key(flat, 'name', 'ok')
41
+ if (flat.name !== 'ok') fail('single-key assignment broke')
42
+
43
+ console.log("✅ F-0016 prototype-pollution regression tests passed")
44
+ }
45
+
46
+ if (require.main === module) {
47
+ prototype_pollution_tests()
48
+ .then(() => { console.log("✅ suite completed"); process.exit(0) })
49
+ .catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
50
+ }
@@ -0,0 +1,161 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../../sdk"
4
+ import {
5
+ assert,
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
+
14
+ // Separate tenant (different businessId), reusing the same hardcoded apiKey that
15
+ // multi_tenant_tests relies on in tests.ts.
16
+ const OTHER_TENANT_API_KEY = "ba745e25162bb95a795c5fa1af70df188d93c4d3aac9c48b34a5c8c9dd7b80f7"
17
+
18
+ /**
19
+ * Tenant-boundary guard for cascade_role_rename (relates to security-audit finding F-0053,
20
+ * which was investigated and closed as a FALSE POSITIVE — see that file for the code trace).
21
+ *
22
+ * The `cascade_role_rename` side-effect handler
23
+ * ([event_handlers_v2/role_based_access_permissions.ts](packages/private/api/api/v1/event_handlers_v2/role_based_access_permissions.ts))
24
+ * runs when a `role_based_access_permissions` doc's `role` field changes. It finds every user
25
+ * with the old role name (via `DBUnrestricted.users`) and rewrites their `roles` array, then
26
+ * deauthenticates them. F-0053 hypothesized this query was globally cross-tenant. It is NOT:
27
+ * `DBUnrestricted` bypasses per-user/per-role RBAC but is STILL scoped to the acting session's
28
+ * `businessId` (see `modifyFilterForAccessConstraint` injecting `{ businessId }` at
29
+ * database.ts:1761-1763, reached via the `unrestricted: true` branch at database.ts:2137-2144).
30
+ *
31
+ * This test locks that boundary in place so a future refactor of `DBUnrestricted` semantics
32
+ * can't silently turn the cascade into a cross-tenant write:
33
+ * 1. Tenant A creates a role `ROLE_OLD` and assigns it to a Tenant A user (positive control).
34
+ * 2. Tenant B (separate businessId) has a user whose roles include the SAME `ROLE_OLD`.
35
+ * 3. Tenant A renames the role `ROLE_OLD` -> `ROLE_NEW`.
36
+ * 4. Assert the Tenant B user's roles are UNCHANGED (still `[ROLE_OLD]`) <-- guards the tenant boundary.
37
+ * 5. Assert the Tenant A user's roles ARE renamed to `[ROLE_NEW]` <-- same-tenant cascade works.
38
+ *
39
+ * Expected on current (correct) code: BOTH assertions pass. A regression that drops the
40
+ * `businessId` scoping would flip assertion #4 to red (Tenant B user becomes `[ROLE_NEW]`).
41
+ *
42
+ * A collision-proof unique role name (timestamped) is used so the test never touches real roles.
43
+ */
44
+ export const cascade_role_rename_cross_tenant_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
45
+ log_header("F-0053: cascade role rename cross-tenant regression")
46
+
47
+ const stamp = Date.now()
48
+ const ROLE_OLD = `XTenantRename_${stamp}`
49
+ const ROLE_NEW = `${ROLE_OLD}_renamed`
50
+
51
+ // Tenant B = a genuinely separate businessId (the apiKey's org). admin is required to create /
52
+ // set roles on users, so the apiKey user must be an Admin in its org.
53
+ const sdkOther = new Session({ host, apiKey: OTHER_TENANT_API_KEY })
54
+
55
+ let rbapId: string | undefined
56
+ // We create dedicated throwaway users in BOTH tenants and delete them in `finally`. We never
57
+ // mutate any pre-existing user (an earlier version of this test clobbered the apiKey's own
58
+ // admin user via getSome+replaceObjectFields — see F-0053 finding notes). controlUser lives in
59
+ // Tenant A and SHOULD be renamed by the same-tenant cascade; victimUser lives in Tenant B and
60
+ // must be UNTOUCHED.
61
+ let controlUserId: string | undefined
62
+ let victimUserId: string | undefined
63
+
64
+ try {
65
+ const tenantABusinessId = sdk.userInfo.businessId
66
+
67
+ // 1. Tenant A: create the role to be renamed.
68
+ const rbap = await sdk.api.role_based_access_permissions.createOne({
69
+ role: ROLE_OLD,
70
+ permissions: { ...PROVIDER_PERMISSIONS },
71
+ })
72
+ rbapId = rbap.id
73
+
74
+ // 2. Tenant A: create a throwaway control user holding ROLE_OLD (notification emails off).
75
+ const controlUser = await sdk.api.users.createOne({
76
+ email: `f0053-control-${stamp}@example.com`,
77
+ notificationEmailsDisabled: true,
78
+ } as any)
79
+ controlUserId = controlUser.id
80
+ await sdk.api.users.updateOne(controlUserId, { roles: [ROLE_OLD] }, { replaceObjectFields: true })
81
+
82
+ // 3. Tenant B: create a throwaway victim user holding the SAME ROLE_OLD.
83
+ const victimUser = await sdkOther.api.users.createOne({
84
+ email: `f0053-victim-${stamp}@example.com`,
85
+ notificationEmailsDisabled: true,
86
+ } as any)
87
+ victimUserId = victimUser.id
88
+ await sdkOther.api.users.updateOne(victimUserId, { roles: [ROLE_OLD] }, { replaceObjectFields: true })
89
+
90
+ // 4. Setup sanity: tenants are distinct and both users actually hold ROLE_OLD before the rename.
91
+ // (Distinguishes a setup failure from the security assertion below.)
92
+ assert(
93
+ victimUser.businessId !== tenantABusinessId,
94
+ `Victim user shares businessId with Tenant A (${victimUser.businessId}) — not a cross-tenant scenario`,
95
+ 'F-0053 setup: tenants are distinct',
96
+ )
97
+ const victimBefore = await sdkOther.api.users.getOne(victimUserId)
98
+ const controlBefore = await sdk.api.users.getOne(controlUserId)
99
+ assert(
100
+ JSON.stringify(victimBefore.roles) === JSON.stringify([ROLE_OLD])
101
+ && JSON.stringify(controlBefore.roles) === JSON.stringify([ROLE_OLD]),
102
+ `Setup failed: expected both users to hold [${ROLE_OLD}] (victim=${JSON.stringify(victimBefore.roles)}, control=${JSON.stringify(controlBefore.roles)})`,
103
+ 'F-0053 setup: both users hold ROLE_OLD',
104
+ )
105
+
106
+ // 5. Tenant A renames the role. This triggers cascade_role_rename.
107
+ await sdk.api.role_based_access_permissions.updateOne(rbapId, { role: ROLE_NEW })
108
+ await wait(undefined, 1500) // let the side effect run
109
+
110
+ // 6. SECURITY ASSERTION — the Tenant B victim must be untouched by Tenant A's rename.
111
+ const victimAfter = await sdkOther.api.users.getOne(victimUserId)
112
+ assert(
113
+ JSON.stringify(victimAfter.roles) === JSON.stringify([ROLE_OLD]),
114
+ `CROSS-TENANT LEAK: Tenant B victim roles changed to ${JSON.stringify(victimAfter.roles)} `
115
+ + `after Tenant A renamed its role. Expected [${ROLE_OLD}].`,
116
+ 'F-0053: Tenant B user roles unaffected by other-tenant role rename',
117
+ )
118
+
119
+ // 7. POSITIVE CONTROL — the Tenant A control user SHOULD be renamed (same-tenant cascade intact).
120
+ const controlAfter = await sdk.api.users.getOne(controlUserId)
121
+ assert(
122
+ JSON.stringify(controlAfter.roles) === JSON.stringify([ROLE_NEW]),
123
+ `Same-tenant cascade broken: Tenant A control roles are ${JSON.stringify(controlAfter.roles)}, `
124
+ + `expected [${ROLE_NEW}].`,
125
+ 'F-0053: Tenant A user roles renamed by same-tenant cascade',
126
+ )
127
+ } finally {
128
+ // Cleanup: delete both throwaway users and the role doc. Never touches pre-existing users.
129
+ if (victimUserId) {
130
+ try { await sdkOther.api.users.deleteOne(victimUserId) } catch {}
131
+ }
132
+ if (controlUserId) {
133
+ try { await sdk.api.users.deleteOne(controlUserId) } catch {}
134
+ }
135
+ if (rbapId) {
136
+ try { await sdk.api.role_based_access_permissions.deleteOne(rbapId) } catch {}
137
+ }
138
+ }
139
+ }
140
+
141
+ // Allow running this test file independently
142
+ if (require.main === module) {
143
+ console.log(`🌐 Using API URL: ${host}`)
144
+ const sdk = new Session({ host })
145
+ const sdkNonAdmin = new Session({ host })
146
+
147
+ const runTests = async () => {
148
+ await setup_tests(sdk, sdkNonAdmin)
149
+ await cascade_role_rename_cross_tenant_tests({ sdk, sdkNonAdmin })
150
+ }
151
+
152
+ runTests()
153
+ .then(() => {
154
+ console.log("✅ F-0053 cascade role rename cross-tenant test suite completed successfully")
155
+ process.exit(0)
156
+ })
157
+ .catch((error) => {
158
+ console.error("❌ F-0053 cascade role rename cross-tenant test suite failed:", error)
159
+ process.exit(1)
160
+ })
161
+ }
@@ -0,0 +1,165 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../../sdk"
4
+ import {
5
+ assert,
6
+ log_header,
7
+ wait,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ /**
14
+ * Self-role privilege-escalation guard (relates to security-audit finding F-0076, which was
15
+ * investigated and closed as a FALSE POSITIVE — see that file for the full code trace).
16
+ *
17
+ * F-0076 hypothesized that a non-admin staff user could `PATCH /v1/users/{their-own-id}` with
18
+ * `{ roles: ['Admin'] }` and self-promote to Admin, because the FIRST `users` relationship
19
+ * constraint ("Only admin users can set the admin role",
20
+ * [schema.ts:3446](packages/public/schema/src/schema.ts#L3446)) has a self-exception
21
+ * (`if (_id === session.id) return`).
22
+ *
23
+ * That analysis missed the SECOND constraint, "Only admin users can update user roles"
24
+ * ([schema.ts:3486](packages/public/schema/src/schema.ts#L3486)), which has NO self-exception.
25
+ * Relationship constraints are AND-evaluated — `validateRelationshipConstraints`
26
+ * ([routing.ts:1240-1252](packages/private/api/api/modules/routing.ts#L1240)) loops the whole
27
+ * array and throws 400 on the FIRST evaluator that returns a string. So a non-admin self-update
28
+ * that includes `roles` passes constraint #1 (self-exception) but is rejected by constraint #2.
29
+ * The self-promotion is blocked.
30
+ *
31
+ * This test locks that boundary in place so a future refactor of the role constraints can't
32
+ * silently reintroduce the escalation. A dedicated throwaway non-admin user is used as the
33
+ * "attacker" (we never mutate the shared sdkNonAdmin's roles):
34
+ * 1. Admin creates a throwaway user and assigns it a non-admin role (`['Provider']`).
35
+ * 2. Authenticate AS that user via a freshly-minted auth token.
36
+ * 3. As the attacker, attempt four self-role mutations — ['Admin'], ['Provider','Admin'],
37
+ * an arbitrary role, and [] — and assert EACH is blocked. <-- the security assertions
38
+ * 4. Confirm (as admin) the attacker's roles are still ['Provider'] — nothing slipped through.
39
+ * 5. Positive control: admin CAN update the throwaway user's roles. <-- guards against an
40
+ * over-restrictive regression that would block legitimate admin role management.
41
+ *
42
+ * Expected on current (correct) code: all assertions pass. A regression that made the self-update
43
+ * path role-writable by non-admins would flip the step-3/step-4 assertions to red.
44
+ */
45
+ export const self_admin_role_assignment_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
46
+ log_header("F-0076: self-admin role assignment privilege-escalation regression")
47
+
48
+ const stamp = Date.now()
49
+ const NON_ADMIN_ROLE = 'Provider'
50
+
51
+ // Assert that a self-role-update attempt is rejected by the relationship constraints.
52
+ const expect_blocked = async (fn: () => Promise<any>, description: string) => {
53
+ try {
54
+ await fn()
55
+ assert(false, `${description} - SELF-ROLE ESCALATION SUCCEEDED (expected it to be blocked)`)
56
+ } catch (e: any) {
57
+ // CRUD relationship-constraint failures surface as 400 { message, info } via SDK parseError.
58
+ assert(
59
+ e?.code === 400 || e?.statusCode === 400 || typeof e?.message === 'string',
60
+ `${description} - expected a block error, got: ${JSON.stringify(e)}`,
61
+ description,
62
+ )
63
+ }
64
+ }
65
+
66
+ let attackerId: string | undefined
67
+
68
+ try {
69
+ // 1. Admin creates a throwaway non-admin user (notification emails off, timestamped email).
70
+ // verifiedEmail is set at creation (admin-only, updatesDisabled after) so the attacker can
71
+ // drive an authenticated session — otherwise actions are blocked on email-verification, not
72
+ // on the role constraint we're actually testing.
73
+ const attacker = await sdk.api.users.createOne({
74
+ email: `f0076-attacker-${stamp}@example.com`,
75
+ notificationEmailsDisabled: true,
76
+ verifiedEmail: true,
77
+ } as any)
78
+ attackerId = attacker.id
79
+ await sdk.api.users.updateOne(attackerId, { roles: [NON_ADMIN_ROLE] }, { replaceObjectFields: true })
80
+ await wait(undefined, 2000) // role change triggers a logout; let it propagate before minting a token
81
+
82
+ // Setup sanity: the attacker holds exactly the non-admin role and is NOT an admin.
83
+ const attackerBefore = await sdk.api.users.getOne(attackerId)
84
+ assert(
85
+ JSON.stringify(attackerBefore.roles) === JSON.stringify([NON_ADMIN_ROLE]),
86
+ `Setup failed: expected attacker to hold [${NON_ADMIN_ROLE}], got ${JSON.stringify(attackerBefore.roles)}`,
87
+ 'F-0076 setup: attacker holds a non-admin role',
88
+ )
89
+
90
+ // 2. Authenticate AS the attacker (no password needed — admin mints an auth token).
91
+ const sdkAttacker = new Session({
92
+ host,
93
+ authToken: (await sdk.api.users.generate_auth_token({ id: attackerId })).authToken,
94
+ })
95
+ await sdkAttacker.refresh_session() // populate userInfo from the freshly-minted token
96
+ assert(
97
+ sdkAttacker.userInfo.id === attackerId && !(sdkAttacker.userInfo.roles ?? []).includes('Admin'),
98
+ `Setup failed: attacker session is not the expected non-admin user`,
99
+ 'F-0076 setup: authenticated as the non-admin attacker',
100
+ )
101
+
102
+ // 3. SECURITY ASSERTIONS — every self-role mutation by the non-admin must be blocked.
103
+ await expect_blocked(
104
+ () => sdkAttacker.api.users.updateOne(attackerId!, { roles: ['Admin'] }, { replaceObjectFields: true }),
105
+ 'F-0076: non-admin self-update to [Admin] is blocked',
106
+ )
107
+ await expect_blocked(
108
+ () => sdkAttacker.api.users.updateOne(attackerId!, { roles: [NON_ADMIN_ROLE, 'Admin'] }, { replaceObjectFields: true }),
109
+ 'F-0076: non-admin self-update to [Provider, Admin] is blocked',
110
+ )
111
+ await expect_blocked(
112
+ () => sdkAttacker.api.users.updateOne(attackerId!, { roles: [`Arbitrary_${stamp}`] }, { replaceObjectFields: true }),
113
+ 'F-0076: non-admin self-update to an arbitrary role is blocked',
114
+ )
115
+ await expect_blocked(
116
+ () => sdkAttacker.api.users.updateOne(attackerId!, { roles: [] }, { replaceObjectFields: true }),
117
+ 'F-0076: non-admin self-update to [] (would grant defaults) is blocked',
118
+ )
119
+
120
+ // 4. STATE ASSERTION — nothing slipped through; roles are still the original non-admin role.
121
+ const attackerAfter = await sdk.api.users.getOne(attackerId)
122
+ assert(
123
+ JSON.stringify(attackerAfter.roles) === JSON.stringify([NON_ADMIN_ROLE]),
124
+ `ESCALATION LEAK: attacker roles changed to ${JSON.stringify(attackerAfter.roles)} `
125
+ + `after self-update attempts. Expected [${NON_ADMIN_ROLE}].`,
126
+ 'F-0076: attacker roles unchanged after all self-escalation attempts',
127
+ )
128
+
129
+ // 5. POSITIVE CONTROL — an Admin CAN update the user's roles (mechanism is not over-restricted).
130
+ await sdk.api.users.updateOne(attackerId, { roles: [NON_ADMIN_ROLE] }, { replaceObjectFields: true })
131
+ const afterAdminUpdate = await sdk.api.users.getOne(attackerId)
132
+ assert(
133
+ JSON.stringify(afterAdminUpdate.roles) === JSON.stringify([NON_ADMIN_ROLE]),
134
+ `Admin role update failed: roles are ${JSON.stringify(afterAdminUpdate.roles)}, expected [${NON_ADMIN_ROLE}]`,
135
+ 'F-0076: admin can manage user roles (positive control)',
136
+ )
137
+ } finally {
138
+ // Cleanup: delete the throwaway attacker. Never touches pre-existing users.
139
+ if (attackerId) {
140
+ try { await sdk.api.users.deleteOne(attackerId) } catch {}
141
+ }
142
+ }
143
+ }
144
+
145
+ // Allow running this test file independently
146
+ if (require.main === module) {
147
+ console.log(`🌐 Using API URL: ${host}`)
148
+ const sdk = new Session({ host })
149
+ const sdkNonAdmin = new Session({ host })
150
+
151
+ const runTests = async () => {
152
+ await setup_tests(sdk, sdkNonAdmin)
153
+ await self_admin_role_assignment_tests({ sdk, sdkNonAdmin })
154
+ }
155
+
156
+ runTests()
157
+ .then(() => {
158
+ console.log("✅ F-0076 self-admin role assignment test suite completed successfully")
159
+ process.exit(0)
160
+ })
161
+ .catch((error) => {
162
+ console.error("❌ F-0076 self-admin role assignment test suite failed:", error)
163
+ process.exit(1)
164
+ })
165
+ }