@tellescope/sdk 1.253.1 → 1.254.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.
- package/lib/cjs/sdk.d.ts +4 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/_tmp_bc_verify.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/_tmp_blank_status_verify.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/_tmp_followup_verify.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/_tmp_nonmatch_verify.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js +187 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/field_redaction.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/field_redaction.test.js +112 -99
- package/lib/cjs/tests/api_tests/field_redaction.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/journey_error_branching.test.js +7 -2
- package/lib/cjs/tests/api_tests/journey_error_branching.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +443 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/resource_access_tags.test.d.ts +22 -0
- package/lib/cjs/tests/api_tests/resource_access_tags.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/resource_access_tags.test.js +488 -0
- package/lib/cjs/tests/api_tests/resource_access_tags.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +362 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js +139 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +156 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js +157 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/time_tracks.test.d.ts +8 -0
- package/lib/cjs/tests/api_tests/time_tracks.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/time_tracks.test.js +638 -1
- package/lib/cjs/tests/api_tests/time_tracks.test.js.map +1 -1
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +20 -1
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +606 -178
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +6 -2
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/_tmp_bc_verify.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/_tmp_bc_verify.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_bc_verify.test.js +120 -0
- package/lib/esm/tests/api_tests/_tmp_bc_verify.test.js.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.js +200 -0
- package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.js.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_followup_verify.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/_tmp_followup_verify.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_followup_verify.test.js +194 -0
- package/lib/esm/tests/api_tests/_tmp_followup_verify.test.js.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.js +115 -0
- package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.js.map +1 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js +183 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
- package/lib/esm/tests/api_tests/field_redaction.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/field_redaction.test.js +112 -99
- package/lib/esm/tests/api_tests/field_redaction.test.js.map +1 -1
- package/lib/esm/tests/api_tests/journey_error_branching.test.js +7 -2
- package/lib/esm/tests/api_tests/journey_error_branching.test.js.map +1 -1
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +439 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
- package/lib/esm/tests/api_tests/resource_access_tags.test.d.ts +22 -0
- package/lib/esm/tests/api_tests/resource_access_tags.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/resource_access_tags.test.js +484 -0
- package/lib/esm/tests/api_tests/resource_access_tags.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +358 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js +135 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +152 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js +150 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
- package/lib/esm/tests/api_tests/time_tracks.test.d.ts +8 -0
- package/lib/esm/tests/api_tests/time_tracks.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/time_tracks.test.js +635 -0
- package/lib/esm/tests/api_tests/time_tracks.test.js.map +1 -1
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +20 -1
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +607 -179
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/field_redaction.test.ts +13 -0
- package/src/tests/api_tests/journey_error_branching.test.ts +5 -1
- package/src/tests/api_tests/mdi_case_offerings.test.ts +414 -0
- package/src/tests/api_tests/resource_access_tags.test.ts +303 -0
- package/src/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.ts +221 -0
- package/src/tests/api_tests/security/F-0145-globalunique-leak.test.ts +78 -0
- package/src/tests/api_tests/security/F-0151-load-inbox-redaction.test.ts +81 -0
- package/src/tests/api_tests/security/F-0155-webhook-timeout.test.ts +85 -0
- package/src/tests/api_tests/time_tracks.test.ts +420 -0
- package/src/tests/setup.ts +9 -0
- package/src/tests/tests.ts +308 -5
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
handleAnyError,
|
|
7
|
+
log_header,
|
|
8
|
+
wait,
|
|
9
|
+
assert,
|
|
10
|
+
} from "@tellescope/testing"
|
|
11
|
+
import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
12
|
+
import { setup_tests } from "../setup"
|
|
13
|
+
|
|
14
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
15
|
+
|
|
16
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
17
|
+
|
|
18
|
+
const TAG_A = 'resource-access-tag-A'
|
|
19
|
+
const TAG_B = 'resource-access-tag-B'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tests for settings.users.enableResourceAccessTags (the `erat` session flag).
|
|
23
|
+
*
|
|
24
|
+
* When the org setting is enabled, org-level configuration resources carrying `accessTags`
|
|
25
|
+
* become visible only to non-admin (Default/Assigned) users whose own tags intersect the
|
|
26
|
+
* record's accessTags. This mirrors the existing enduser accessTags behavior, generalized
|
|
27
|
+
* to the 9 RESOURCE_ACCESS_TAG_MODELS.
|
|
28
|
+
*
|
|
29
|
+
* The model is ADDITIVE: a record only becomes tag-gated once an admin sets accessTags on it.
|
|
30
|
+
* Records without accessTags keep their existing rules (Default == creator-only). Admins and
|
|
31
|
+
* All-access users are unaffected.
|
|
32
|
+
*
|
|
33
|
+
* Per the throwaway-user testing guidance, this test creates throwaway users rather than
|
|
34
|
+
* mutating existing user roles. Tokens are minted via generate_auth_token AFTER the org
|
|
35
|
+
* setting is enabled, so the `erat` flag lands in their sessions.
|
|
36
|
+
*/
|
|
37
|
+
export const resource_access_tags_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
38
|
+
log_header("Resource Access Tags Tests")
|
|
39
|
+
|
|
40
|
+
const DEFAULT_RW = { create: 'Default', read: 'Default', update: 'Default', delete: 'Default' } as const
|
|
41
|
+
const ALL_RW = { create: 'All', read: 'All', update: 'All', delete: 'All' } as const
|
|
42
|
+
|
|
43
|
+
const RESOURCE_MODELS = [
|
|
44
|
+
'templates', 'forms', 'journeys', 'automation_triggers', 'calendar_event_templates',
|
|
45
|
+
'appointment_booking_pages', 'table_views', 'files', 'managed_content_records',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
// track everything we create for cleanup
|
|
49
|
+
const createdUserIds: string[] = []
|
|
50
|
+
const createdRoleIds: string[] = []
|
|
51
|
+
const createdByModel: Record<string, string[]> = {}
|
|
52
|
+
for (const m of RESOURCE_MODELS) createdByModel[m] = []
|
|
53
|
+
let helperTemplateId: string | undefined // calendar_event_template used as a booking-page dependency
|
|
54
|
+
|
|
55
|
+
const orgId = sdk.userInfo.businessId
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// ── 1. Enable the org setting ───────────────────────────────────────────────
|
|
59
|
+
// Explicitly disable enduser access tags (eat) so that the tag-edit-restriction
|
|
60
|
+
// assertions below prove the `erat` flag alone enforces the protection (otherwise
|
|
61
|
+
// a residual `eat` could mask the gap). Settings deep-merge, so this is targeted.
|
|
62
|
+
await sdk.api.organizations.updateOne(orgId, {
|
|
63
|
+
settings: { users: { enableResourceAccessTags: true }, endusers: { enableAccessTags: false } },
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── 2. Roles: Default read for a tag-gated user, All read for a control user ──
|
|
67
|
+
const defaultPermissions: any = { ...PROVIDER_PERMISSIONS }
|
|
68
|
+
const allPermissions: any = { ...PROVIDER_PERMISSIONS }
|
|
69
|
+
for (const m of RESOURCE_MODELS) {
|
|
70
|
+
defaultPermissions[m] = { ...DEFAULT_RW }
|
|
71
|
+
allPermissions[m] = { ...ALL_RW }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const defaultRole = `resource-access-tags-default-${RAND()}`
|
|
75
|
+
const allRole = `resource-access-tags-all-${RAND()}`
|
|
76
|
+
const rbapDefault = await sdk.api.role_based_access_permissions.createOne({ role: defaultRole, permissions: defaultPermissions })
|
|
77
|
+
const rbapAll = await sdk.api.role_based_access_permissions.createOne({ role: allRole, permissions: allPermissions })
|
|
78
|
+
createdRoleIds.push(rbapDefault.id, rbapAll.id)
|
|
79
|
+
|
|
80
|
+
// ── 3. Throwaway users + tokens (minted AFTER enabling the setting → erat set) ─
|
|
81
|
+
const mkUser = async (tags: string[], roles: string[]) => {
|
|
82
|
+
const user = await sdk.api.users.createOne({
|
|
83
|
+
email: `resource-access-tags-${RAND()}@tellescope.example`,
|
|
84
|
+
fname: 'Resource', lname: 'AccessTags',
|
|
85
|
+
notificationEmailsDisabled: true, verifiedEmail: true,
|
|
86
|
+
tags, roles,
|
|
87
|
+
} as any)
|
|
88
|
+
createdUserIds.push(user.id)
|
|
89
|
+
// mint AFTER the setting is enabled so the erat flag lands in the session/JWT
|
|
90
|
+
const { authToken, user: sessionUser } = await sdk.api.users.generate_auth_token({ id: user.id })
|
|
91
|
+
const session = new Session({ host })
|
|
92
|
+
session.setAuthToken(authToken)
|
|
93
|
+
session.setUserInfo(sessionUser as any)
|
|
94
|
+
return { session, sessionUser: sessionUser as any }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { session: sdkWithTag, sessionUser: withTagUser } = await mkUser([TAG_A], [defaultRole]) // Default access, carries the gating tag
|
|
98
|
+
const { session: sdkWithoutTag } = await mkUser([TAG_B], [defaultRole]) // Default access, lacks the gating tag
|
|
99
|
+
const { session: sdkAllAccess } = await mkUser([TAG_B], [allRole]) // All access, lacks the tag (should still see everything)
|
|
100
|
+
|
|
101
|
+
// sanity check: erat made it into the gated sessions
|
|
102
|
+
assert(!!withTagUser.erat, 'erat flag missing on tagged user session', 'erat flag present on tagged user session')
|
|
103
|
+
|
|
104
|
+
// ── 4. Per-model record creation (tagged with TAG_A + untagged control) ──────
|
|
105
|
+
// calendar_event_template dependency for booking pages
|
|
106
|
+
const helperTemplate = await sdk.api.calendar_event_templates.createOne({ title: `RAT helper ${RAND()}`, durationInMinutes: 30 })
|
|
107
|
+
helperTemplateId = helperTemplate.id
|
|
108
|
+
createdByModel['calendar_event_templates'].push(helperTemplate.id)
|
|
109
|
+
|
|
110
|
+
const createRecord = async (model: typeof RESOURCE_MODELS[number], accessTags?: string[]): Promise<string> => {
|
|
111
|
+
const tags = accessTags ? { accessTags } : {}
|
|
112
|
+
let id: string
|
|
113
|
+
switch (model) {
|
|
114
|
+
case 'templates': {
|
|
115
|
+
const r = await sdk.api.templates.createOne({ title: `RAT tmpl ${RAND()}`, subject: 's', html: '<p>x</p>', message: 'm', ...tags } as any)
|
|
116
|
+
id = r.id; break
|
|
117
|
+
}
|
|
118
|
+
case 'forms': {
|
|
119
|
+
const r = await sdk.api.forms.createOne({ title: `RAT form ${RAND()}`, ...tags } as any)
|
|
120
|
+
id = r.id; break
|
|
121
|
+
}
|
|
122
|
+
case 'journeys': {
|
|
123
|
+
const r = await sdk.api.journeys.createOne({ title: `RAT jrn ${RAND()}`, ...tags } as any)
|
|
124
|
+
id = r.id; break
|
|
125
|
+
}
|
|
126
|
+
case 'automation_triggers': {
|
|
127
|
+
const r = await sdk.api.automation_triggers.createOne({
|
|
128
|
+
title: `RAT trg ${RAND()}`, status: 'Active',
|
|
129
|
+
event: { type: 'Appointment Completed', info: {} },
|
|
130
|
+
action: { type: 'Add Tags', info: { tags: ['x'] } },
|
|
131
|
+
...tags,
|
|
132
|
+
} as any)
|
|
133
|
+
id = r.id; break
|
|
134
|
+
}
|
|
135
|
+
case 'calendar_event_templates': {
|
|
136
|
+
const r = await sdk.api.calendar_event_templates.createOne({ title: `RAT cet ${RAND()}`, durationInMinutes: 30, ...tags } as any)
|
|
137
|
+
id = r.id; break
|
|
138
|
+
}
|
|
139
|
+
case 'appointment_booking_pages': {
|
|
140
|
+
const r = await sdk.api.appointment_booking_pages.createOne({
|
|
141
|
+
title: `RAT bp ${RAND()}`, calendarEventTemplateIds: [helperTemplateId!], locationIds: [], ...tags,
|
|
142
|
+
} as any)
|
|
143
|
+
id = r.id; break
|
|
144
|
+
}
|
|
145
|
+
case 'table_views': {
|
|
146
|
+
const r = await sdk.api.table_views.createOne({ title: `RAT view ${RAND()}`, page: 'Ticket', columns: [], ...tags } as any)
|
|
147
|
+
id = r.id; break
|
|
148
|
+
}
|
|
149
|
+
case 'files': {
|
|
150
|
+
const { file } = await sdk.api.files.prepare_file_upload({ name: `RAT file ${RAND()}`, type: 'text/plain', size: 1 })
|
|
151
|
+
if (accessTags) await sdk.api.files.updateOne(file.id, { accessTags } as any)
|
|
152
|
+
id = file.id; break
|
|
153
|
+
}
|
|
154
|
+
case 'managed_content_records': {
|
|
155
|
+
const r = await sdk.api.managed_content_records.createOne({ title: `RAT content ${RAND()}`, textContent: 't', ...tags } as any)
|
|
156
|
+
id = r.id; break
|
|
157
|
+
}
|
|
158
|
+
default: throw new Error(`unhandled model ${model}`)
|
|
159
|
+
}
|
|
160
|
+
createdByModel[model].push(id)
|
|
161
|
+
return id
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const recordsByModel: Record<string, { taggedId: string, untaggedId: string }> = {}
|
|
165
|
+
for (const model of RESOURCE_MODELS) {
|
|
166
|
+
const taggedId = await createRecord(model, [TAG_A])
|
|
167
|
+
const untaggedId = await createRecord(model)
|
|
168
|
+
recordsByModel[model] = { taggedId, untaggedId }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── 5. Assertions per model ──────────────────────────────────────────────────
|
|
172
|
+
const visibleIds = async (session: Session, model: string, ids: string[]): Promise<Set<string>> => {
|
|
173
|
+
const records = await (session.api as any)[model].getSome({ ids, limit: 100 })
|
|
174
|
+
return new Set(records.map((r: any) => r.id))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const model of RESOURCE_MODELS) {
|
|
178
|
+
const { taggedId, untaggedId } = recordsByModel[model]
|
|
179
|
+
const ids = [taggedId, untaggedId]
|
|
180
|
+
|
|
181
|
+
const withTag = await visibleIds(sdkWithTag, model, ids)
|
|
182
|
+
const withoutTag = await visibleIds(sdkWithoutTag, model, ids)
|
|
183
|
+
const admin = await visibleIds(sdk, model, ids)
|
|
184
|
+
const allAccess = await visibleIds(sdkAllAccess, model, ids)
|
|
185
|
+
|
|
186
|
+
await async_test(
|
|
187
|
+
`${model}: user WITH matching tag CAN read the tagged record`,
|
|
188
|
+
async () => withTag.has(taggedId),
|
|
189
|
+
{ onResult: r => r === true },
|
|
190
|
+
)
|
|
191
|
+
await async_test(
|
|
192
|
+
`${model}: user WITHOUT matching tag CANNOT read the tagged record`,
|
|
193
|
+
async () => withoutTag.has(taggedId),
|
|
194
|
+
{ onResult: r => r === false },
|
|
195
|
+
)
|
|
196
|
+
await async_test(
|
|
197
|
+
`${model}: untagged admin record NOT visible to tagged user (additive model)`,
|
|
198
|
+
async () => withTag.has(untaggedId),
|
|
199
|
+
{ onResult: r => r === false },
|
|
200
|
+
)
|
|
201
|
+
await async_test(
|
|
202
|
+
`${model}: untagged admin record NOT visible to untagged user (additive model)`,
|
|
203
|
+
async () => withoutTag.has(untaggedId),
|
|
204
|
+
{ onResult: r => r === false },
|
|
205
|
+
)
|
|
206
|
+
await async_test(
|
|
207
|
+
`${model}: admin sees both records (gating does not apply)`,
|
|
208
|
+
async () => admin.has(taggedId) && admin.has(untaggedId),
|
|
209
|
+
{ onResult: r => r === true },
|
|
210
|
+
)
|
|
211
|
+
await async_test(
|
|
212
|
+
`${model}: All-access user sees both records (gating does not apply)`,
|
|
213
|
+
async () => allAccess.has(taggedId) && allAccess.has(untaggedId),
|
|
214
|
+
{ onResult: r => r === true },
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 5b. Tag-edit restriction: a non-admin cannot edit tags to self-escalate ──
|
|
219
|
+
// With erat ON and eat OFF, the users.tags edit guard must still fire — proving
|
|
220
|
+
// resource access tags (not just enduser access tags) trigger the protection.
|
|
221
|
+
log_header("Resource Access Tags: tag-edit blocked")
|
|
222
|
+
const withTagUserId = createdUserIds[0]
|
|
223
|
+
await async_test(
|
|
224
|
+
`non-admin can't update own tags (erat enabled)`,
|
|
225
|
+
() => sdkWithTag.api.users.updateOne(withTagUserId, { tags: ['escalate'] }),
|
|
226
|
+
handleAnyError,
|
|
227
|
+
)
|
|
228
|
+
await async_test(
|
|
229
|
+
`non-admin can't update own tags alongside other fields (erat enabled)`,
|
|
230
|
+
() => sdkWithTag.api.users.updateOne(withTagUserId, { tags: ['escalate'], bio: '' }),
|
|
231
|
+
handleAnyError,
|
|
232
|
+
)
|
|
233
|
+
await async_test(
|
|
234
|
+
`non-admin can still update non-tag fields (control)`,
|
|
235
|
+
() => sdkWithTag.api.users.updateOne(withTagUserId, { bio: '' }),
|
|
236
|
+
{ shouldError: false, onResult: () => true },
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// ── 6. Org-setting-OFF control: disabling the flag gates the whole feature ────
|
|
240
|
+
log_header("Resource Access Tags: org-setting-OFF control")
|
|
241
|
+
await sdk.api.organizations.updateOne(orgId, {
|
|
242
|
+
settings: { users: { enableResourceAccessTags: false } },
|
|
243
|
+
})
|
|
244
|
+
// re-mint the tagged user's token so the (now-disabled) setting is reflected in the session
|
|
245
|
+
const { authToken: reAuthToken, user: reAuthUser } = await sdk.api.users.generate_auth_token({ id: createdUserIds[0] })
|
|
246
|
+
const sdkWithTagOff = new Session({ host })
|
|
247
|
+
sdkWithTagOff.setAuthToken(reAuthToken)
|
|
248
|
+
sdkWithTagOff.setUserInfo(reAuthUser as any)
|
|
249
|
+
assert(!(reAuthUser as any).erat, 'erat flag should be absent after disabling setting', 'erat flag absent after disabling setting')
|
|
250
|
+
|
|
251
|
+
const { taggedId: templatesTaggedId } = recordsByModel['templates']
|
|
252
|
+
const offVisible = await visibleIds(sdkWithTagOff, 'templates', [templatesTaggedId])
|
|
253
|
+
await async_test(
|
|
254
|
+
`templates: tagged record NOT visible to tagged user once setting is OFF`,
|
|
255
|
+
async () => offVisible.has(templatesTaggedId),
|
|
256
|
+
{ onResult: r => r === false },
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
console.log("\n" + "=".repeat(60))
|
|
260
|
+
console.log("Resource Access Tags Tests Complete")
|
|
261
|
+
console.log("=".repeat(60))
|
|
262
|
+
} finally {
|
|
263
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
264
|
+
for (const model of RESOURCE_MODELS) {
|
|
265
|
+
for (const id of createdByModel[model]) {
|
|
266
|
+
await (sdk.api as any)[model].deleteOne(id).catch(() => {})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const id of createdRoleIds) {
|
|
270
|
+
await sdk.api.role_based_access_permissions.deleteOne(id).catch(() => {})
|
|
271
|
+
}
|
|
272
|
+
for (const id of createdUserIds) {
|
|
273
|
+
await sdk.api.users.deleteOne(id).catch(() => {})
|
|
274
|
+
}
|
|
275
|
+
// reset the org setting
|
|
276
|
+
await sdk.api.organizations.updateOne(orgId, {
|
|
277
|
+
settings: { users: { enableResourceAccessTags: false } },
|
|
278
|
+
}).catch(() => {})
|
|
279
|
+
await wait(undefined, 250)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Allow running this test file independently
|
|
284
|
+
if (require.main === module) {
|
|
285
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
286
|
+
const sdk = new Session({ host })
|
|
287
|
+
const sdkNonAdmin = new Session({ host })
|
|
288
|
+
|
|
289
|
+
const runTests = async () => {
|
|
290
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
291
|
+
await resource_access_tags_tests({ sdk, sdkNonAdmin })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
runTests()
|
|
295
|
+
.then(() => {
|
|
296
|
+
console.log("✅ Resource access tags test suite completed successfully")
|
|
297
|
+
process.exit(0)
|
|
298
|
+
})
|
|
299
|
+
.catch((error) => {
|
|
300
|
+
console.error("❌ Resource access tags test suite failed:", error)
|
|
301
|
+
process.exit(1)
|
|
302
|
+
})
|
|
303
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { setup_tests } from "../../setup"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
const businessId = '60398b1131a295e64f084ff6'
|
|
12
|
+
|
|
13
|
+
const ENDUSER_PASSWORD = 'F0116TestPassword!123'
|
|
14
|
+
|
|
15
|
+
const CROSS_ENDUSER_MESSAGE = "enduserId does not match creator id for enduser session"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Regression test for the enduser cross-enduser CREATE gating gaps:
|
|
19
|
+
* F-0116 enduser_observations (security-audit/findings/F-0116-*.md)
|
|
20
|
+
* F-0117 enduser_orders (security-audit/findings/F-0117-*.md)
|
|
21
|
+
* F-0119 managed_content_records (security-audit/findings/F-0119-*.md)
|
|
22
|
+
* F-0118 chat_rooms (enduserIds-only gate; staff userIds targeting is by-design)
|
|
23
|
+
*
|
|
24
|
+
* Each model exposed enduserActions.create with an `enduserId`/`enduserIds` field that
|
|
25
|
+
* lacked a write-side gate, letting an authenticated enduser create records bound to a
|
|
26
|
+
* DIFFERENT enduser in the same tenant. The fix adds a `constraints.relationship`
|
|
27
|
+
* evaluator mirroring the canonical `tickets` pattern (schema.ts).
|
|
28
|
+
*
|
|
29
|
+
* Methodology: each section asserts BOTH the exploit-is-blocked case AND that the
|
|
30
|
+
* legitimate path (self-create + staff-create) still works.
|
|
31
|
+
*/
|
|
32
|
+
export const enduser_cross_create_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
33
|
+
log_header("F-0116/F-0117/F-0118/F-0119: enduser cross-enduser create gates")
|
|
34
|
+
|
|
35
|
+
let attackerId: string | undefined
|
|
36
|
+
let victimId: string | undefined
|
|
37
|
+
const createdObservationIds: string[] = []
|
|
38
|
+
const createdOrderIds: string[] = []
|
|
39
|
+
const createdContentIds: string[] = []
|
|
40
|
+
const createdRoomIds: string[] = []
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// ---- Setup: attacker enduser (with portal session) + victim enduser ----
|
|
44
|
+
const attackerEmail = `f0116-attacker-${Date.now()}@example.com`
|
|
45
|
+
const victimEmail = `f0116-victim-${Date.now()}@example.com`
|
|
46
|
+
const attacker = await sdk.api.endusers.createOne({ email: attackerEmail, fname: 'F0116', lname: 'Attacker' })
|
|
47
|
+
attackerId = attacker.id
|
|
48
|
+
const victim = await sdk.api.endusers.createOne({ email: victimEmail, fname: 'F0116', lname: 'Victim' })
|
|
49
|
+
victimId = victim.id
|
|
50
|
+
await sdk.api.endusers.set_password({ id: attacker.id, password: ENDUSER_PASSWORD })
|
|
51
|
+
|
|
52
|
+
const attackerSDK = new EnduserSession({ host, businessId })
|
|
53
|
+
await attackerSDK.authenticate(attackerEmail, ENDUSER_PASSWORD)
|
|
54
|
+
|
|
55
|
+
// ============================================================
|
|
56
|
+
// F-0116 — enduser_observations
|
|
57
|
+
// ============================================================
|
|
58
|
+
await async_test(
|
|
59
|
+
`F-0116: enduser cannot create observation on another enduser (createOne)`,
|
|
60
|
+
() => attackerSDK.api.enduser_observations.createOne({
|
|
61
|
+
enduserId: victimId!, category: 'vital-signs', status: 'final',
|
|
62
|
+
measurement: { unit: 'mmHg', value: 220 },
|
|
63
|
+
} as any),
|
|
64
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
65
|
+
)
|
|
66
|
+
await async_test(
|
|
67
|
+
`F-0116: enduser cannot create observation on another enduser (createMany)`,
|
|
68
|
+
() => attackerSDK.api.enduser_observations.createSome([{
|
|
69
|
+
enduserId: victimId!, category: 'vital-signs', status: 'final',
|
|
70
|
+
measurement: { unit: 'mmHg', value: 220 },
|
|
71
|
+
}] as any),
|
|
72
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Positive control: enduser CAN create an observation on their OWN record
|
|
76
|
+
await async_test(
|
|
77
|
+
`F-0116: enduser can still create observation on own record`,
|
|
78
|
+
() => attackerSDK.api.enduser_observations.createOne({
|
|
79
|
+
enduserId: attackerId!, category: 'vital-signs', status: 'final',
|
|
80
|
+
measurement: { unit: 'mmHg', value: 120 },
|
|
81
|
+
} as any),
|
|
82
|
+
{ onResult: (o: any) => { if (o?.id) createdObservationIds.push(o.id); return o?.enduserId === attackerId } },
|
|
83
|
+
)
|
|
84
|
+
// Positive control: staff CAN create an observation on any tenant enduser
|
|
85
|
+
await async_test(
|
|
86
|
+
`F-0116: staff can still create observation on any enduser`,
|
|
87
|
+
() => sdk.api.enduser_observations.createOne({
|
|
88
|
+
enduserId: victimId!, category: 'vital-signs', status: 'final',
|
|
89
|
+
measurement: { unit: 'mmHg', value: 118 },
|
|
90
|
+
} as any),
|
|
91
|
+
{ onResult: (o: any) => { if (o?.id) createdObservationIds.push(o.id); return o?.enduserId === victimId } },
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// F-0117 — enduser_orders
|
|
96
|
+
// ============================================================
|
|
97
|
+
const orderPayload = (enduserId: string) => ({
|
|
98
|
+
enduserId, externalId: `f0117-${Date.now()}`, source: 'f0117-source',
|
|
99
|
+
title: 'f0117 order', status: 'pending',
|
|
100
|
+
})
|
|
101
|
+
await async_test(
|
|
102
|
+
`F-0117: enduser cannot create order on another enduser`,
|
|
103
|
+
() => attackerSDK.api.enduser_orders.createOne(orderPayload(victimId!) as any),
|
|
104
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
105
|
+
)
|
|
106
|
+
await async_test(
|
|
107
|
+
`F-0117: enduser can still create order on own record`,
|
|
108
|
+
() => attackerSDK.api.enduser_orders.createOne(orderPayload(attackerId!) as any),
|
|
109
|
+
{ onResult: (o: any) => { if (o?.id) createdOrderIds.push(o.id); return o?.enduserId === attackerId } },
|
|
110
|
+
)
|
|
111
|
+
await async_test(
|
|
112
|
+
`F-0117: staff can still create order on any enduser`,
|
|
113
|
+
() => sdk.api.enduser_orders.createOne(orderPayload(victimId!) as any),
|
|
114
|
+
{ onResult: (o: any) => { if (o?.id) createdOrderIds.push(o.id); return o?.enduserId === victimId } },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// ============================================================
|
|
118
|
+
// F-0119 — managed_content_records
|
|
119
|
+
// ============================================================
|
|
120
|
+
await async_test(
|
|
121
|
+
`F-0119: enduser cannot create content with allowUnauthenticatedAccess`,
|
|
122
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
123
|
+
title: 'f0119 phishing', textContent: 'x', allowUnauthenticatedAccess: true,
|
|
124
|
+
} as any),
|
|
125
|
+
{ shouldError: true, onError: e => e.message === "allowUnauthenticatedAccess cannot be set by endusers" },
|
|
126
|
+
)
|
|
127
|
+
await async_test(
|
|
128
|
+
`F-0119: enduser cannot create content with publicRead`,
|
|
129
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
130
|
+
title: 'f0119 broadcast', textContent: 'x', publicRead: true,
|
|
131
|
+
} as any),
|
|
132
|
+
{ shouldError: true, onError: e => e.message === "publicRead cannot be set by endusers" },
|
|
133
|
+
)
|
|
134
|
+
await async_test(
|
|
135
|
+
`F-0119: enduser cannot create content bound to another enduser`,
|
|
136
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
137
|
+
title: 'f0119 cross', textContent: 'x', enduserId: victimId!,
|
|
138
|
+
} as any),
|
|
139
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
140
|
+
)
|
|
141
|
+
await async_test(
|
|
142
|
+
`F-0119: enduser can still create ordinary content`,
|
|
143
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
144
|
+
title: 'f0119 ok', textContent: 'hello',
|
|
145
|
+
} as any),
|
|
146
|
+
{ onResult: (o: any) => { if (o?.id) createdContentIds.push(o.id); return !o?.publicRead && !o?.allowUnauthenticatedAccess } },
|
|
147
|
+
)
|
|
148
|
+
await async_test(
|
|
149
|
+
`F-0119: staff can still create public content`,
|
|
150
|
+
() => sdk.api.managed_content_records.createOne({
|
|
151
|
+
title: 'f0119 staff public', textContent: 'x', publicRead: true,
|
|
152
|
+
} as any),
|
|
153
|
+
{ onResult: (o: any) => { if (o?.id) createdContentIds.push(o.id); return o?.publicRead === true } },
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// ============================================================
|
|
157
|
+
// F-0118 — chat_rooms (enduserIds-only gate; staff userIds targeting is by-design)
|
|
158
|
+
// ============================================================
|
|
159
|
+
const staffId = sdk.userInfo.id
|
|
160
|
+
await async_test(
|
|
161
|
+
`F-0118: enduser cannot add another patient to a chat room (enduserIds)`,
|
|
162
|
+
() => attackerSDK.api.chat_rooms.createOne({
|
|
163
|
+
type: 'internal', enduserIds: [attackerId!, victimId!], userIds: [staffId],
|
|
164
|
+
} as any),
|
|
165
|
+
{ shouldError: true, onError: e => e.message === "enduserIds may only contain your own id for enduser session" },
|
|
166
|
+
)
|
|
167
|
+
// Positive control: patient CAN create a room with care-team staff + self (intended feature)
|
|
168
|
+
await async_test(
|
|
169
|
+
`F-0118: enduser can still create a room with staff + self`,
|
|
170
|
+
() => attackerSDK.api.chat_rooms.createOne({
|
|
171
|
+
type: 'internal', enduserIds: [attackerId!], userIds: [staffId],
|
|
172
|
+
} as any),
|
|
173
|
+
{ onResult: (o: any) => { if (o?.id) createdRoomIds.push(o.id); return o?.enduserIds?.length === 1 && o?.userIds?.includes(staffId) } },
|
|
174
|
+
)
|
|
175
|
+
// Positive control: staff can still create multi-patient rooms
|
|
176
|
+
await async_test(
|
|
177
|
+
`F-0118: staff can still create a room with multiple endusers`,
|
|
178
|
+
() => sdk.api.chat_rooms.createOne({
|
|
179
|
+
type: 'internal', enduserIds: [attackerId!, victimId!], userIds: [staffId],
|
|
180
|
+
} as any),
|
|
181
|
+
{ onResult: (o: any) => { if (o?.id) createdRoomIds.push(o.id); return o?.enduserIds?.length === 2 } },
|
|
182
|
+
)
|
|
183
|
+
} finally {
|
|
184
|
+
for (const id of createdRoomIds) {
|
|
185
|
+
try { await sdk.api.chat_rooms.deleteOne(id) } catch {}
|
|
186
|
+
}
|
|
187
|
+
for (const id of createdContentIds) {
|
|
188
|
+
try { await sdk.api.managed_content_records.deleteOne(id) } catch {}
|
|
189
|
+
}
|
|
190
|
+
for (const id of createdObservationIds) {
|
|
191
|
+
try { await sdk.api.enduser_observations.deleteOne(id) } catch {}
|
|
192
|
+
}
|
|
193
|
+
for (const id of createdOrderIds) {
|
|
194
|
+
try { await sdk.api.enduser_orders.deleteOne(id) } catch {}
|
|
195
|
+
}
|
|
196
|
+
if (attackerId) { try { await sdk.api.endusers.deleteOne(attackerId) } catch {} }
|
|
197
|
+
if (victimId) { try { await sdk.api.endusers.deleteOne(victimId) } catch {} }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Allow running this test file independently
|
|
202
|
+
if (require.main === module) {
|
|
203
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
204
|
+
const sdk = new Session({ host })
|
|
205
|
+
const sdkNonAdmin = new Session({ host })
|
|
206
|
+
|
|
207
|
+
const runTests = async () => {
|
|
208
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
209
|
+
await enduser_cross_create_tests({ sdk, sdkNonAdmin })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
runTests()
|
|
213
|
+
.then(() => {
|
|
214
|
+
console.log("✅ F-0116/F-0117/F-0118/F-0119 enduser cross-create test suite completed successfully")
|
|
215
|
+
process.exit(0)
|
|
216
|
+
})
|
|
217
|
+
.catch((error) => {
|
|
218
|
+
console.error("❌ enduser cross-create test suite failed:", error)
|
|
219
|
+
process.exit(1)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { setup_tests } from "../../setup"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression test for F-0145
|
|
14
|
+
* (security-audit/findings/F-0145-globalunique-conflict-leaks-cross-tenant-record.md).
|
|
15
|
+
*
|
|
16
|
+
* The create endpoint generator's unconditional globalUnique-enforcement block
|
|
17
|
+
* (routing.ts, "globalUnique should be enforced even if _overrideUnique is true")
|
|
18
|
+
* threw uniquenessError([{ existingRecord: matches[0] }]) with the RAW matched record.
|
|
19
|
+
* `findSomeByMatchAny_Global` deletes businessId, so the match is global; for `users`
|
|
20
|
+
* (globalUnique: ['email','phone']) the 409 body leaked the full record incl. hashedPass.
|
|
21
|
+
*
|
|
22
|
+
* Fix: redact existingRecord to { _id, businessId } like the safe validateUniquenessConstraints
|
|
23
|
+
* path. This reproduces the leak with a same-tenant collision (same code path) and asserts the
|
|
24
|
+
* 409 body's existingRecord exposes nothing beyond _id + businessId.
|
|
25
|
+
*/
|
|
26
|
+
export const globalunique_leak_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
27
|
+
log_header("F-0145: globalUnique conflict response redaction")
|
|
28
|
+
|
|
29
|
+
let userId: string | undefined
|
|
30
|
+
try {
|
|
31
|
+
const email = `f0145-victim-${Date.now()}@example.com`
|
|
32
|
+
const user = await sdk.api.users.createOne({ email } as any)
|
|
33
|
+
userId = user.id
|
|
34
|
+
|
|
35
|
+
await async_test(
|
|
36
|
+
'F-0145: globalUnique 409 existingRecord exposes only _id + businessId (no email/hashedPass)',
|
|
37
|
+
() => sdk.api.users.createOne({ email, _overrideUnique: true } as any),
|
|
38
|
+
{
|
|
39
|
+
shouldError: true,
|
|
40
|
+
onError: (e: any) => {
|
|
41
|
+
const rec = e?.info?.[0]?.existingRecord
|
|
42
|
+
if (!rec || typeof rec !== 'object') return false
|
|
43
|
+
const keys = Object.keys(rec)
|
|
44
|
+
return (
|
|
45
|
+
keys.length > 0
|
|
46
|
+
&& keys.every(k => k === '_id' || k === 'businessId' || k === 'id')
|
|
47
|
+
&& !('email' in rec)
|
|
48
|
+
&& !('hashedPass' in rec)
|
|
49
|
+
&& !('hashedInviteCode' in rec)
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
} finally {
|
|
55
|
+
if (userId) { try { await sdk.api.users.deleteOne(userId) } catch {} }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (require.main === module) {
|
|
60
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
61
|
+
const sdk = new Session({ host })
|
|
62
|
+
const sdkNonAdmin = new Session({ host })
|
|
63
|
+
|
|
64
|
+
const runTests = async () => {
|
|
65
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
66
|
+
await globalunique_leak_tests({ sdk, sdkNonAdmin })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
runTests()
|
|
70
|
+
.then(() => {
|
|
71
|
+
console.log("✅ F-0145 globalUnique leak test suite completed successfully")
|
|
72
|
+
process.exit(0)
|
|
73
|
+
})
|
|
74
|
+
.catch((error) => {
|
|
75
|
+
console.error("❌ F-0145 globalUnique leak test suite failed:", error)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { setup_tests } from "../../setup"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
|
|
12
|
+
const ENDUSER_PASSWORD = 'F0151TestPassword!123'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Regression test for F-0151
|
|
16
|
+
* (security-audit/findings/F-0151-load-inbox-data-endusers-unredacted.md).
|
|
17
|
+
*
|
|
18
|
+
* load_inbox_data serialized $lookup'ed endusers via convert_id_name only (NOT applyRedactions),
|
|
19
|
+
* so endusers.hashedPassword (redactions:['all']) leaked to staff sessions — while the sibling
|
|
20
|
+
* phone_calls in the same response correctly went through applyRedactions.
|
|
21
|
+
*
|
|
22
|
+
* Repro: create an enduser, set their portal password (→ hashedPassword), seed an inbound
|
|
23
|
+
* (logOnly) email so the enduser appears in the inbox join, then load_inbox_data as staff and
|
|
24
|
+
* assert the returned enduser entry has NO hashedPassword.
|
|
25
|
+
*/
|
|
26
|
+
export const load_inbox_redaction_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
27
|
+
log_header("F-0151: load_inbox_data enduser redaction")
|
|
28
|
+
|
|
29
|
+
let enduserId: string | undefined
|
|
30
|
+
let emailId: string | undefined
|
|
31
|
+
try {
|
|
32
|
+
const email = `f0151-enduser-${Date.now()}@example.com`
|
|
33
|
+
const enduser = await sdk.api.endusers.createOne({ email, fname: 'F0151', lname: 'Inbox' })
|
|
34
|
+
enduserId = enduser.id
|
|
35
|
+
await sdk.api.endusers.set_password({ id: enduser.id, password: ENDUSER_PASSWORD })
|
|
36
|
+
|
|
37
|
+
// Seed an inbound logOnly email so the enduser is joined into the inbox payload
|
|
38
|
+
const seeded = await sdk.api.emails.createOne({
|
|
39
|
+
enduserId: enduser.id, subject: 'F0151 seed', textContent: 'seed',
|
|
40
|
+
logOnly: true, inbound: true,
|
|
41
|
+
} as any)
|
|
42
|
+
emailId = seeded.id
|
|
43
|
+
|
|
44
|
+
await async_test(
|
|
45
|
+
'F-0151: load_inbox_data does not leak enduser.hashedPassword to staff',
|
|
46
|
+
() => sdk.api.endusers.load_inbox_data({ enduserIds: [enduser.id] } as any),
|
|
47
|
+
{
|
|
48
|
+
onResult: (r: any) => {
|
|
49
|
+
const returned = (r?.endusers ?? []).find((e: any) => (e.id || e._id)?.toString() === enduserId)
|
|
50
|
+
// require that the enduser actually came back (so the assertion is meaningful),
|
|
51
|
+
// and that hashedPassword is absent
|
|
52
|
+
return !!returned && !('hashedPassword' in returned)
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
} finally {
|
|
57
|
+
if (emailId) { try { await sdk.api.emails.deleteOne(emailId) } catch {} }
|
|
58
|
+
if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (require.main === module) {
|
|
63
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
64
|
+
const sdk = new Session({ host })
|
|
65
|
+
const sdkNonAdmin = new Session({ host })
|
|
66
|
+
|
|
67
|
+
const runTests = async () => {
|
|
68
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
69
|
+
await load_inbox_redaction_tests({ sdk, sdkNonAdmin })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
runTests()
|
|
73
|
+
.then(() => {
|
|
74
|
+
console.log("✅ F-0151 load_inbox_data redaction test suite completed successfully")
|
|
75
|
+
process.exit(0)
|
|
76
|
+
})
|
|
77
|
+
.catch((error) => {
|
|
78
|
+
console.error("❌ F-0151 load_inbox_data redaction test suite failed:", error)
|
|
79
|
+
process.exit(1)
|
|
80
|
+
})
|
|
81
|
+
}
|