@tellescope/sdk 1.255.16 → 1.255.17
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 +1 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/organization_api_keys.test.js +2 -2
- package/lib/cjs/tests/api_tests/organization_api_keys.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +314 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +147 -143
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +3 -3
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/organization_api_keys.test.js +2 -2
- package/lib/esm/tests/api_tests/organization_api_keys.test.js.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +307 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +147 -143
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/sdk.ts +3 -0
- package/src/tests/api_tests/organization_api_keys.test.ts +2 -2
- package/src/tests/api_tests/scoped_api_keys.test.ts +237 -0
- package/src/tests/tests.ts +2 -0
- package/test_generated.pdf +0 -0
|
@@ -12,7 +12,7 @@ import { setup_tests } from "../setup"
|
|
|
12
12
|
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
13
|
|
|
14
14
|
// fields that the admin org-wide list endpoint is allowed to return
|
|
15
|
-
const ALLOWED_FIELDS = ['id', 'creator', 'businessId', 'updatedAt'].sort()
|
|
15
|
+
const ALLOWED_FIELDS = ['id', 'creator', 'businessId', 'updatedAt', 'scopes'].sort()
|
|
16
16
|
|
|
17
17
|
export const organization_api_keys_tests = async (
|
|
18
18
|
{ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
|
|
@@ -104,7 +104,7 @@ export const organization_api_keys_tests = async (
|
|
|
104
104
|
assert(
|
|
105
105
|
offending === undefined,
|
|
106
106
|
`a returned record exposed non-allowlisted fields: ${JSON.stringify(offending)}`,
|
|
107
|
-
"returned records contain only allowlisted fields (subset of id, creator, businessId, updatedAt)"
|
|
107
|
+
"returned records contain only allowlisted fields (subset of id, creator, businessId, updatedAt, scopes)"
|
|
108
108
|
)
|
|
109
109
|
|
|
110
110
|
// =============================================
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
import { Session } from "../../sdk"
|
|
5
|
+
import {
|
|
6
|
+
assert,
|
|
7
|
+
async_test,
|
|
8
|
+
log_header,
|
|
9
|
+
wait,
|
|
10
|
+
} from "@tellescope/testing"
|
|
11
|
+
import { setup_tests } from "../setup"
|
|
12
|
+
import { FORM_INGESTION_API_KEY_SCOPE } from "@tellescope/types-models"
|
|
13
|
+
|
|
14
|
+
const host = process.env.API_URL || "http://localhost:8080"
|
|
15
|
+
|
|
16
|
+
const TEST_EMAIL = "scoped-api-keys@tellescope.com"
|
|
17
|
+
|
|
18
|
+
export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
19
|
+
log_header("Scoped API Keys (form-ingestion)")
|
|
20
|
+
|
|
21
|
+
const form = await sdk.api.forms.createOne({ title: "Scoped API Key Test Form" })
|
|
22
|
+
|
|
23
|
+
const buildUrl = (pathSegment: string) => {
|
|
24
|
+
const url = new URL(`${host}/v1/webhooks/form-ingestion/${pathSegment}`)
|
|
25
|
+
url.searchParams.set('formId', form.id)
|
|
26
|
+
url.searchParams.set('returnJSON', 'true')
|
|
27
|
+
return url.toString()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const bodyFor = (responder_uuid: string) => ({
|
|
31
|
+
answers: [{ key: 'email', value: TEST_EMAIL }],
|
|
32
|
+
responder_uuid,
|
|
33
|
+
finalized: true,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const keyIds: string[] = []
|
|
37
|
+
const formResponseIds: string[] = []
|
|
38
|
+
let throwawayUserId: string | undefined
|
|
39
|
+
let throwawayRoleId: string | undefined
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// =============================================
|
|
43
|
+
// (a) Admin creates a scoped key
|
|
44
|
+
// =============================================
|
|
45
|
+
const scoped = await sdk.api.api_keys.createOne({ scopes: [FORM_INGESTION_API_KEY_SCOPE] })
|
|
46
|
+
keyIds.push(scoped.id)
|
|
47
|
+
assert(!!scoped.id && !!scoped.key, "scoped key create returned no id/key", "admin creates a form-ingestion-scoped key")
|
|
48
|
+
|
|
49
|
+
// record reflects the scope for its creator (CREATOR_ONLY_ACCESS read)
|
|
50
|
+
await async_test(
|
|
51
|
+
"scoped key record stores scopes",
|
|
52
|
+
() => sdk.api.api_keys.getOne(scoped.id),
|
|
53
|
+
{ onResult: k => JSON.stringify(k.scopes) === JSON.stringify([FORM_INGESTION_API_KEY_SCOPE]) },
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// =============================================
|
|
57
|
+
// (b) Scoped key is rejected on all general /v1 auth
|
|
58
|
+
// =============================================
|
|
59
|
+
await async_test(
|
|
60
|
+
"scoped key rejected on /v1/test-authenticated",
|
|
61
|
+
() => new Session({ host, apiKey: scoped.key }).test_authenticated(),
|
|
62
|
+
// SDK parseError surfaces a raw 401 body as the string 'Unauthenticated'
|
|
63
|
+
{ shouldError: true, onError: (e: any) => e === 'Unauthenticated' || e?.statusCode === 401 || /unauthenticated/i.test(e?.message || '') },
|
|
64
|
+
)
|
|
65
|
+
await async_test(
|
|
66
|
+
"scoped key rejected on GET /v1/endusers",
|
|
67
|
+
() => axios.get(`${host}/v1/endusers`, { headers: { Authorization: `API_KEY ${scoped.key}` } }),
|
|
68
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
69
|
+
)
|
|
70
|
+
await async_test(
|
|
71
|
+
"scoped key rejected on cross-org targeting (x-tellescope-organization)",
|
|
72
|
+
() => axios.get(`${host}/v1/endusers`, {
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `API_KEY ${scoped.key}`,
|
|
75
|
+
'x-tellescope-organization': sdk.userInfo.businessId,
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// =============================================
|
|
82
|
+
// (c) Scoped key IS accepted by the form-ingestion webhook
|
|
83
|
+
// =============================================
|
|
84
|
+
const headerResult = await axios.post(
|
|
85
|
+
buildUrl('key-in-header'),
|
|
86
|
+
bodyFor("scoped-1"),
|
|
87
|
+
{ headers: { Authorization: `API_KEY ${scoped.key}` } },
|
|
88
|
+
)
|
|
89
|
+
assert(
|
|
90
|
+
headerResult.status === 200 && !!headerResult.data?.formResponseId,
|
|
91
|
+
`scoped key header auth failed: ${headerResult.status} ${JSON.stringify(headerResult.data)}`,
|
|
92
|
+
"scoped key authenticates the form-ingestion webhook via header",
|
|
93
|
+
)
|
|
94
|
+
if (headerResult.data?.formResponseId) formResponseIds.push(headerResult.data.formResponseId)
|
|
95
|
+
|
|
96
|
+
const pathResult = await axios.post(buildUrl(scoped.key), bodyFor("scoped-2"))
|
|
97
|
+
assert(
|
|
98
|
+
pathResult.status === 200 && !!pathResult.data?.formResponseId,
|
|
99
|
+
`scoped key path auth failed: ${pathResult.status} ${JSON.stringify(pathResult.data)}`,
|
|
100
|
+
"scoped key authenticates the form-ingestion webhook via path segment",
|
|
101
|
+
)
|
|
102
|
+
if (pathResult.data?.formResponseId) formResponseIds.push(pathResult.data.formResponseId)
|
|
103
|
+
|
|
104
|
+
// =============================================
|
|
105
|
+
// (d) Non-admin cannot create a scoped key (throwaway user, never mutate existing roles)
|
|
106
|
+
// =============================================
|
|
107
|
+
const apiKeysRole = await sdk.api.role_based_access_permissions.createOne({
|
|
108
|
+
role: 'scoped-api-keys-test-role',
|
|
109
|
+
permissions: { api_keys: { create: 'All', read: 'All', update: 'All', delete: 'All' } },
|
|
110
|
+
})
|
|
111
|
+
throwawayRoleId = apiKeysRole.id
|
|
112
|
+
|
|
113
|
+
const throwawayUser = (
|
|
114
|
+
await sdk.api.users.getOne({ email: 'scoped.api.keys.user@tellescope.com' }).catch(() => null)
|
|
115
|
+
) || (
|
|
116
|
+
await sdk.api.users.createOne({ email: 'scoped.api.keys.user@tellescope.com', notificationEmailsDisabled: true, verifiedEmail: true })
|
|
117
|
+
)
|
|
118
|
+
throwawayUserId = throwawayUser.id
|
|
119
|
+
await sdk.api.users.updateOne(throwawayUser.id, { roles: [apiKeysRole.role] }, { replaceObjectFields: true })
|
|
120
|
+
await wait(undefined, 2000) // wait for the role change to propagate before authenticating
|
|
121
|
+
|
|
122
|
+
const otherSdk = new Session({
|
|
123
|
+
host,
|
|
124
|
+
authToken: (await sdk.api.users.generate_auth_token({ id: throwawayUser.id })).authToken,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await async_test(
|
|
128
|
+
"non-admin cannot create a scoped key (401)",
|
|
129
|
+
() => otherSdk.api.api_keys.createOne({ scopes: [FORM_INGESTION_API_KEY_SCOPE] }),
|
|
130
|
+
{ shouldError: true, onError: (e: any) => e === 'Unauthenticated' || e?.statusCode === 401 || /admin/i.test(e?.message || '') },
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// ...but CAN still create an unscoped key; an empty scopes list is rejected by validation (fail closed)
|
|
134
|
+
const nonAdminKey = await otherSdk.api.api_keys.createOne({})
|
|
135
|
+
await async_test(
|
|
136
|
+
"empty scopes list rejected by validation",
|
|
137
|
+
() => otherSdk.api.api_keys.createOne({ scopes: [] }),
|
|
138
|
+
{ shouldError: true, onError: (e: any) => e?.statusCode === 400 || /empty|not valid|invalid/i.test(e?.message || '') },
|
|
139
|
+
)
|
|
140
|
+
// creator-only cleanup for the throwaway user's key before the user is deleted
|
|
141
|
+
await otherSdk.api.api_keys.deleteOne(nonAdminKey.id).catch(() => {})
|
|
142
|
+
|
|
143
|
+
// =============================================
|
|
144
|
+
// (e) Unscoped keys are unchanged (no regression)
|
|
145
|
+
// =============================================
|
|
146
|
+
const unscoped = await sdk.api.api_keys.createOne({})
|
|
147
|
+
const unscopedKeyValue = (unscoped as any).key as string
|
|
148
|
+
keyIds.push(unscoped.id)
|
|
149
|
+
|
|
150
|
+
await async_test(
|
|
151
|
+
"unscoped key still authenticates general /v1",
|
|
152
|
+
() => new Session({ host, apiKey: unscopedKeyValue }).test_authenticated(),
|
|
153
|
+
{ onResult: r => !!r },
|
|
154
|
+
)
|
|
155
|
+
// Frozen pre-existing behavior: unscoped /v1-created keys store a utf8-encoded hashedKey, which the
|
|
156
|
+
// webhook's hex lookup never matches — only legacy plaintext keys and hex-stored scoped keys work there.
|
|
157
|
+
// (Legacy-key webhook auth is covered by formsort_header_auth.test.ts.)
|
|
158
|
+
await async_test(
|
|
159
|
+
"unscoped /v1-created key still does NOT authenticate the webhook (frozen encoding quirk)",
|
|
160
|
+
() => axios.post(
|
|
161
|
+
buildUrl('key-in-header'),
|
|
162
|
+
bodyFor("scoped-3"),
|
|
163
|
+
{ headers: { Authorization: `API_KEY ${unscopedKeyValue}` } },
|
|
164
|
+
),
|
|
165
|
+
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// =============================================
|
|
169
|
+
// (f) Admin org-wide list includes scopes (and still never leaks secrets)
|
|
170
|
+
// =============================================
|
|
171
|
+
const { apiKeys } = await sdk.api.api_keys.get_organization_api_keys()
|
|
172
|
+
assert(
|
|
173
|
+
!!apiKeys.find(k => k.id === scoped.id && JSON.stringify(k.scopes) === JSON.stringify([FORM_INGESTION_API_KEY_SCOPE])),
|
|
174
|
+
"org list did not report scopes on the scoped key",
|
|
175
|
+
"get_organization_api_keys reports scopes on scoped keys",
|
|
176
|
+
)
|
|
177
|
+
assert(
|
|
178
|
+
!!apiKeys.find(k => k.id === unscoped.id && k.scopes === undefined),
|
|
179
|
+
"org list reported scopes on an unscoped key",
|
|
180
|
+
"get_organization_api_keys omits scopes on unscoped keys",
|
|
181
|
+
)
|
|
182
|
+
assert(
|
|
183
|
+
apiKeys.every(k => (k as any).key === undefined && (k as any).hashedKey === undefined),
|
|
184
|
+
"org list leaked key material",
|
|
185
|
+
"get_organization_api_keys still never returns key/hashedKey",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// =============================================
|
|
189
|
+
// Validation: unknown scope values are rejected
|
|
190
|
+
// =============================================
|
|
191
|
+
await async_test(
|
|
192
|
+
"unknown scope value rejected by validation",
|
|
193
|
+
() => sdk.api.api_keys.createOne({ scopes: ['not-a-real-scope'] as any }),
|
|
194
|
+
{ shouldError: true, onError: (e: any) => e?.statusCode === 400 || /must match|not valid|invalid/i.test(e?.message || '') },
|
|
195
|
+
)
|
|
196
|
+
} finally {
|
|
197
|
+
for (const id of keyIds) {
|
|
198
|
+
await sdk.api.api_keys.deleteOne(id).catch(() => {})
|
|
199
|
+
}
|
|
200
|
+
for (const id of formResponseIds) {
|
|
201
|
+
await sdk.api.form_responses.deleteOne(id).catch(() => {})
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const enduser = await sdk.api.endusers.getOne({ email: TEST_EMAIL })
|
|
205
|
+
await sdk.api.endusers.deleteOne(enduser.id)
|
|
206
|
+
} catch (err) { /* may not exist */ }
|
|
207
|
+
await sdk.api.forms.deleteOne(form.id).catch(() => {})
|
|
208
|
+
if (throwawayUserId) {
|
|
209
|
+
await sdk.api.users.deleteOne(throwawayUserId).catch(() => {})
|
|
210
|
+
}
|
|
211
|
+
if (throwawayRoleId) {
|
|
212
|
+
await sdk.api.role_based_access_permissions.deleteOne(throwawayRoleId).catch(() => {})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Allow running this test file independently
|
|
218
|
+
if (require.main === module) {
|
|
219
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
220
|
+
const sdk = new Session({ host })
|
|
221
|
+
const sdkNonAdmin = new Session({ host })
|
|
222
|
+
|
|
223
|
+
const runTests = async () => {
|
|
224
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
225
|
+
await scoped_api_keys_tests({ sdk, sdkNonAdmin })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
runTests()
|
|
229
|
+
.then(() => {
|
|
230
|
+
console.log("✅ Scoped API keys test suite completed successfully")
|
|
231
|
+
process.exit(0)
|
|
232
|
+
})
|
|
233
|
+
.catch((error) => {
|
|
234
|
+
console.error("❌ Scoped API keys test suite failed:", error)
|
|
235
|
+
process.exit(1)
|
|
236
|
+
})
|
|
237
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -87,6 +87,7 @@ import { enduser_login_rate_limits_tests } from "./api_tests/enduser_login_rate_
|
|
|
87
87
|
import { eom_procedure_codes_tests } from "./api_tests/eom_procedure_codes.test";
|
|
88
88
|
import { cross_org_api_key_tests } from "./api_tests/cross_org_api_key.test";
|
|
89
89
|
import { organization_api_keys_tests } from "./api_tests/organization_api_keys.test";
|
|
90
|
+
import { scoped_api_keys_tests } from "./api_tests/scoped_api_keys.test";
|
|
90
91
|
import { custom_dashboards_tests } from "./api_tests/custom_dashboards.test";
|
|
91
92
|
import { message_assignment_trigger_tests } from "./api_tests/message_assignment_trigger.test";
|
|
92
93
|
import { outbound_chat_sent_trigger_tests } from "./api_tests/outbound_chat_sent_trigger.test";
|
|
@@ -15084,6 +15085,7 @@ const ip_address_form_tests = async () => {
|
|
|
15084
15085
|
await eom_procedure_codes_tests({ sdk, sdkNonAdmin })
|
|
15085
15086
|
await cross_org_api_key_tests({ sdk, sdkNonAdmin })
|
|
15086
15087
|
await organization_api_keys_tests({ sdk, sdkNonAdmin })
|
|
15088
|
+
await scoped_api_keys_tests({ sdk, sdkNonAdmin })
|
|
15087
15089
|
await organization_settings_duplicates_tests({ sdk, sdkNonAdmin })
|
|
15088
15090
|
await enduser_session_invalidation_tests({ sdk, sdkNonAdmin })
|
|
15089
15091
|
await chats_analytics_tests({ sdk, sdkNonAdmin })
|
package/test_generated.pdf
CHANGED
|
Binary file
|