@tellescope/sdk 1.253.0 → 1.254.0
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/beluga_manual_sync.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js +256 -0
- package/lib/cjs/tests/api_tests/beluga_manual_sync.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/gcal_sync_retry.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js +168 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
- 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/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-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +325 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.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/api_tests/user_portal_settings.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js +104 -28
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +878 -200
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +4 -0
- 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/beluga_manual_sync.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/beluga_manual_sync.test.js +252 -0
- package/lib/esm/tests/api_tests/beluga_manual_sync.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/gcal_sync_retry.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.js +164 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
- 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/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-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +321 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.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/api_tests/user_portal_settings.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/user_portal_settings.test.js +104 -28
- package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +879 -201
- 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/beluga_manual_sync.test.ts +159 -0
- package/src/tests/api_tests/field_redaction.test.ts +13 -0
- package/src/tests/api_tests/gcal_sync_retry.test.ts +104 -0
- package/src/tests/api_tests/journey_error_branching.test.ts +5 -1
- package/src/tests/api_tests/resource_access_tags.test.ts +303 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +214 -0
- package/src/tests/api_tests/time_tracks.test.ts +420 -0
- package/src/tests/api_tests/user_portal_settings.test.ts +71 -1
- package/src/tests/tests.ts +520 -11
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
import { BELUGA_TITLE } from "@tellescope/constants"
|
|
10
|
+
|
|
11
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
|
+
|
|
13
|
+
// Manual Beluga re-sync guard tests (CU-86e1uxz1n).
|
|
14
|
+
// - form_responses.push_to_EHR with target === BELUGA_TITLE
|
|
15
|
+
// - files.push with destination === BELUGA_TITLE
|
|
16
|
+
//
|
|
17
|
+
// Fast / no-upload: this only verifies the bad-input / guard branches that reject before any
|
|
18
|
+
// Beluga call. It performs NO S3 file uploads and triggers NO actual sync (both slow and require
|
|
19
|
+
// live Beluga sandbox credentials). File records are created via prepare_file_upload alone — that
|
|
20
|
+
// inserts the DB record, which is all the guards need.
|
|
21
|
+
export const beluga_manual_sync_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
|
|
22
|
+
log_header("Beluga Manual Sync Guard Tests (FormResponses & Files)")
|
|
23
|
+
|
|
24
|
+
const errorMessage = (e: any) => (e?.message || e?.toString?.() || JSON.stringify(e)) as string
|
|
25
|
+
|
|
26
|
+
let enduserId: string | undefined
|
|
27
|
+
let belugaFormId: string | undefined
|
|
28
|
+
let plainFormId: string | undefined
|
|
29
|
+
const fileIds: string[] = []
|
|
30
|
+
let createdBelugaIntegrationId: string | undefined
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
34
|
+
fname: 'beluga-sync',
|
|
35
|
+
email: `beluga_manual_sync_${Date.now()}@test.tellescope.com`,
|
|
36
|
+
})
|
|
37
|
+
enduserId = enduser.id
|
|
38
|
+
|
|
39
|
+
// Beluga-configured form (belugaVisitType set) and a plain (non-Beluga) form
|
|
40
|
+
const belugaForm = await sdk.api.forms.createOne({ title: 'Beluga Manual Sync Form', belugaVisitType: 'sync' })
|
|
41
|
+
belugaFormId = belugaForm.id
|
|
42
|
+
|
|
43
|
+
const plainForm = await sdk.api.forms.createOne({ title: 'Non-Beluga Manual Sync Form' })
|
|
44
|
+
plainFormId = plainForm.id
|
|
45
|
+
// A form needs at least one field + a matching answer to be submittable (empty responses are rejected)
|
|
46
|
+
const plainField = await sdk.api.form_fields.createOne({
|
|
47
|
+
formId: plainForm.id, type: 'string', title: 'Field', previousFields: [{ type: 'root', info: {} }],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const submitForm = async (formId: string) => {
|
|
51
|
+
const { accessCode, response } = await sdk.api.form_responses.prepare_form_response({ enduserId: enduser.id, formId })
|
|
52
|
+
await sdk.api.form_responses.submit_form_response({
|
|
53
|
+
accessCode,
|
|
54
|
+
responses: [{ fieldId: plainField.id, fieldTitle: 'Field', answer: { type: 'string', value: 'x' } }],
|
|
55
|
+
})
|
|
56
|
+
return response.id
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Create a File DB record WITHOUT uploading to S3. prepare_file_upload inserts the record and
|
|
60
|
+
// returns it; skipping sdk.UPLOAD / confirm_file_upload keeps the test fast. The guards only
|
|
61
|
+
// read fields off the record (formResponseId, references), so no real upload is needed.
|
|
62
|
+
const createFileRecord = async (name: string) => {
|
|
63
|
+
const { file } = await sdk.api.files.prepare_file_upload({
|
|
64
|
+
name, type: 'text/plain', size: 1, enduserId: enduser.id,
|
|
65
|
+
})
|
|
66
|
+
fileIds.push(file.id)
|
|
67
|
+
return file
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// 1. FormResponse guards (integration-independent)
|
|
72
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// Unsubmitted draft → rejected before any integration lookup
|
|
75
|
+
const { response: draft } = await sdk.api.form_responses.prepare_form_response({ enduserId: enduser.id, formId: belugaForm.id })
|
|
76
|
+
await async_test(
|
|
77
|
+
"push_to_EHR(target=BELUGA) rejects an unsubmitted form response",
|
|
78
|
+
() => sdk.api.form_responses.push_to_EHR({ id: draft.id, target: BELUGA_TITLE }),
|
|
79
|
+
{ shouldError: true, onError: (e: any) => /has not been submitted/i.test(errorMessage(e)) }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Submitted, but the form has no belugaVisitType → rejected as not configured
|
|
83
|
+
const plainResponseId = await submitForm(plainForm.id)
|
|
84
|
+
await async_test(
|
|
85
|
+
"push_to_EHR(target=BELUGA) rejects a form not configured for Beluga",
|
|
86
|
+
() => sdk.api.form_responses.push_to_EHR({ id: plainResponseId, target: BELUGA_TITLE }),
|
|
87
|
+
{ shouldError: true, onError: (e: any) => /not configured for Beluga/i.test(errorMessage(e)) }
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// 2. files.push(destination=BELUGA) guard — the endpoint resolves the destination
|
|
92
|
+
// integration first, so a Beluga integration must exist for the branch to be reached.
|
|
93
|
+
// Create a placeholder if the org has none; skip gracefully if that's not permitted.
|
|
94
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
let belugaIntegrationAvailable = false
|
|
97
|
+
try {
|
|
98
|
+
const existing = await sdk.api.integrations.load_redacted({})
|
|
99
|
+
belugaIntegrationAvailable = !!existing.integrations.find((i: any) => i.title === BELUGA_TITLE)
|
|
100
|
+
} catch { /* load not permitted — fall through to create attempt */ }
|
|
101
|
+
|
|
102
|
+
if (!belugaIntegrationAvailable) {
|
|
103
|
+
try {
|
|
104
|
+
const created = await sdk.api.integrations.createOne({
|
|
105
|
+
title: BELUGA_TITLE,
|
|
106
|
+
authentication: {
|
|
107
|
+
type: 'oauth2',
|
|
108
|
+
info: { access_token: 'test-access-token', refresh_token: 'test-refresh-token', scope: '', token_type: 'Bearer', expiry_date: new Date().getTime() },
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
createdBelugaIntegrationId = created.id
|
|
112
|
+
belugaIntegrationAvailable = true
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.log("Could not create a Beluga integration for testing; skipping files.push guard:", errorMessage(e))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (belugaIntegrationAvailable) {
|
|
119
|
+
// A file with no associated formResponseId cannot be synced to Beluga
|
|
120
|
+
const unlinkedFile = await createFileRecord('beluga-unlinked.txt')
|
|
121
|
+
await async_test(
|
|
122
|
+
"files.push(destination=BELUGA) rejects a file with no associated form response",
|
|
123
|
+
() => sdk.api.files.push({ id: unlinkedFile.id, destination: BELUGA_TITLE }),
|
|
124
|
+
{ shouldError: true, onError: (e: any) => /not associated with a form response/i.test(errorMessage(e)) }
|
|
125
|
+
)
|
|
126
|
+
} else {
|
|
127
|
+
console.log("⏭️ Skipping files.push(destination=BELUGA) guard (no Beluga integration available)")
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
for (const id of fileIds) {
|
|
131
|
+
await sdk.api.files.deleteOne(id).catch(console.error)
|
|
132
|
+
}
|
|
133
|
+
if (belugaFormId) await sdk.api.forms.deleteOne(belugaFormId).catch(console.error)
|
|
134
|
+
if (plainFormId) await sdk.api.forms.deleteOne(plainFormId).catch(console.error)
|
|
135
|
+
if (enduserId) await sdk.api.endusers.deleteOne(enduserId).catch(console.error)
|
|
136
|
+
if (createdBelugaIntegrationId) await sdk.api.integrations.deleteOne(createdBelugaIntegrationId).catch(console.error)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (require.main === module) {
|
|
141
|
+
console.log(`Using API URL: ${host}`)
|
|
142
|
+
const sdk = new Session({ host })
|
|
143
|
+
const sdkNonAdmin = new Session({ host })
|
|
144
|
+
|
|
145
|
+
const runTests = async () => {
|
|
146
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
147
|
+
await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
runTests()
|
|
151
|
+
.then(() => {
|
|
152
|
+
console.log("✅ Beluga manual sync test suite completed successfully")
|
|
153
|
+
process.exit(0)
|
|
154
|
+
})
|
|
155
|
+
.catch((error) => {
|
|
156
|
+
console.error("❌ Beluga manual sync test suite failed:", error)
|
|
157
|
+
process.exit(1)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
@@ -56,14 +56,27 @@ export const field_redaction_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
56
56
|
const FULL_ACCESS = { create: 'All' as const, read: 'All' as const, update: 'All' as const, delete: 'All' as const }
|
|
57
57
|
|
|
58
58
|
const fullRedactionRole = 'full-redaction-test-role'
|
|
59
|
+
const ROLE_COLOR = '#FF8800'
|
|
60
|
+
const ROLE_DESCRIPTION = 'Role used by field redaction tests'
|
|
59
61
|
const rbapFull = await sdk.api.role_based_access_permissions.createOne({
|
|
60
62
|
role: fullRedactionRole,
|
|
61
63
|
permissions: { ...PROVIDER_PERMISSIONS, phone_calls: FULL_ACCESS, endusers: FULL_ACCESS },
|
|
62
64
|
fieldRedactions: {
|
|
63
65
|
phone_calls: [...ALL_REDACTABLE_FIELDS],
|
|
64
66
|
},
|
|
67
|
+
color: ROLE_COLOR,
|
|
68
|
+
description: ROLE_DESCRIPTION,
|
|
65
69
|
})
|
|
66
70
|
|
|
71
|
+
// Verify color/description persist and round-trip via getOne
|
|
72
|
+
await async_test(
|
|
73
|
+
"role_based_access_permissions - color and description persist on create/read",
|
|
74
|
+
() => sdk.api.role_based_access_permissions.getOne(rbapFull.id),
|
|
75
|
+
{
|
|
76
|
+
onResult: (r: any) => r.color === ROLE_COLOR && r.description === ROLE_DESCRIPTION,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
67
80
|
const originalRoles = sdkNonAdmin.userInfo.roles
|
|
68
81
|
|
|
69
82
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
log_header,
|
|
7
|
+
wait,
|
|
8
|
+
} from "@tellescope/testing"
|
|
9
|
+
import { setup_tests } from "../setup"
|
|
10
|
+
|
|
11
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Google Calendar sync retry — integration coverage.
|
|
15
|
+
*
|
|
16
|
+
* ┌─ ARCHITECTURE NOTE (why the full mock-Google scenarios are gated) ──────────┐
|
|
17
|
+
* │ SDK api_tests run in a SEPARATE process from the API server (they talk to │
|
|
18
|
+
* │ host = API_URL / http://localhost:8080). The retry scheduler, the Google │
|
|
19
|
+
* │ retryable-error predicates, and the google.calendar() client all live in the │
|
|
20
|
+
* │ API SERVER process. A stub installed here (in the SDK test process) cannot │
|
|
21
|
+
* │ replace the server's in-process google client, so we cannot deterministically│
|
|
22
|
+
* │ inject 429 / 500 / ECONNRESET responses from this test alone. │
|
|
23
|
+
* │ │
|
|
24
|
+
* │ Two ways to run the full fail-429-then-succeed / exhaustion / cap scenarios: │
|
|
25
|
+
* │ 1. Run the API server with NODE_ENV=test and RETRY_SCHEDULER_DELAYS_MS=10,20 │
|
|
26
|
+
* │ (already honored by the singleton constructor) plus a server-side │
|
|
27
|
+
* │ fault-injection hook on sdk.events.* that reads e.g. │
|
|
28
|
+
* │ process.env.GCAL_TEST_FORCE_ERROR — that hook does not exist yet. │
|
|
29
|
+
* │ 2. Drive the scheduler directly with the in-process unit tests, which DO │
|
|
30
|
+
* │ cover all retry mechanics deterministically: │
|
|
31
|
+
* │ packages/private/api/api/modules/retry_scheduler.test.ts │
|
|
32
|
+
* │ packages/private/api/api/integrations/google.test.ts │
|
|
33
|
+
* │ │
|
|
34
|
+
* │ The scenario matrix below is recorded for when a server-side hook is added. │
|
|
35
|
+
* └──────────────────────────────────────────────────────────────────────────────┘
|
|
36
|
+
*
|
|
37
|
+
* Scenario matrix (requires server-side Google fault injection — see note):
|
|
38
|
+
* 1. create retry: fail 429 once then succeed -> gcal reference written, NO background_errors
|
|
39
|
+
* 2. exhaustion: fail 429 on every call -> exactly one "Google Calendar Push Error" background_errors row
|
|
40
|
+
* 3. non-retryable: fail 403 -> background_errors written immediately, no retries
|
|
41
|
+
* 4. create idempotency: fail ECONNRESET (network) -> NON-retryable for create (duplicate risk),
|
|
42
|
+
* background_errors written, no retry, no duplicate insert
|
|
43
|
+
* 5. update + delete: same as (1) for events.patch and events.delete
|
|
44
|
+
* 6. cap exceeded: maxOpenRetries=2, 3 events all fail retryably -> 3rd -> background_errors immediately
|
|
45
|
+
*
|
|
46
|
+
* The runnable assertions below cover what IS observable end-to-end without a
|
|
47
|
+
* connected Google account: the refactored call sites must not regress the common
|
|
48
|
+
* "user has no Google integration" path (no sync attempt, no background_errors, no retry).
|
|
49
|
+
*/
|
|
50
|
+
export const gcal_sync_retry_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
51
|
+
log_header("Google Calendar Sync Retry")
|
|
52
|
+
|
|
53
|
+
if (process.env.GCAL_RETRY_INTEGRATION !== '1') {
|
|
54
|
+
console.log("ℹ️ Skipping mock-Google scenarios (set GCAL_RETRY_INTEGRATION=1 with a server-side "
|
|
55
|
+
+ "fault-injection hook to run scenarios 1-6). Running no-integration regression checks only.")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No-integration regression check: a calendar event with a user attendee whose
|
|
59
|
+
// account has no Google integration should sync-skip silently (no background_errors).
|
|
60
|
+
const enduser = await sdk.api.endusers.createOne({ fname: 'GcalRetry', lname: 'Test' })
|
|
61
|
+
|
|
62
|
+
await async_test(
|
|
63
|
+
'create event for user without Google integration does not produce a background error',
|
|
64
|
+
async () => {
|
|
65
|
+
const event = await sdk.api.calendar_events.createOne({
|
|
66
|
+
title: 'Gcal Retry Regression',
|
|
67
|
+
startTimeInMS: Date.now() + 1000 * 60 * 60,
|
|
68
|
+
durationInMinutes: 30,
|
|
69
|
+
attendees: [{ type: 'user', id: sdk.userInfo.id }, { type: 'enduser', id: enduser.id }],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// give side-effect handlers a moment to run
|
|
73
|
+
await wait(undefined, 750)
|
|
74
|
+
|
|
75
|
+
const errors = await sdk.api.background_errors.getSome({}).catch(() => [])
|
|
76
|
+
|
|
77
|
+
// clean up
|
|
78
|
+
await sdk.api.calendar_events.deleteOne(event.id).catch(() => {})
|
|
79
|
+
|
|
80
|
+
// No Google integration on the test user => no push attempt => no error row.
|
|
81
|
+
return errors.filter((e: any) => (
|
|
82
|
+
e.userId === sdk.userInfo.id && e.title === "Google Calendar Push Error"
|
|
83
|
+
)).length
|
|
84
|
+
},
|
|
85
|
+
{ onResult: (count) => count === 0 },
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// cleanup
|
|
89
|
+
await sdk.api.endusers.deleteOne(enduser.id).catch(() => {})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (require.main === module) {
|
|
93
|
+
const sdk = new Session({ host })
|
|
94
|
+
const sdkNonAdmin = new Session({ host })
|
|
95
|
+
|
|
96
|
+
const runTests = async () => {
|
|
97
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
98
|
+
await gcal_sync_retry_tests({ sdk, sdkNonAdmin })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
runTests()
|
|
102
|
+
.then(() => { console.log("✅ gcal sync retry test suite completed successfully"); process.exit(0) })
|
|
103
|
+
.catch((error) => { console.error("❌ gcal sync retry test suite failed:", error); process.exit(1) })
|
|
104
|
+
}
|
|
@@ -428,7 +428,11 @@ const pollForErrorHandling = async <T>(
|
|
|
428
428
|
evaluateFn: (result: T) => boolean,
|
|
429
429
|
description: string,
|
|
430
430
|
intervalMs = 500,
|
|
431
|
-
|
|
431
|
+
// Default window must exceed two worker poll cycles. Automated actions are processed every
|
|
432
|
+
// >= 8s (worker pollingDelaySeconds = Math.max(NUM_THREADS, 8)), and the onError flow needs
|
|
433
|
+
// two cycles (cycle 1: action fails + onError action created; cycle 2: onError tag added),
|
|
434
|
+
// so ~16s worst case. 60 * 500ms = 30s gives buffer. Do not lower below ~30s.
|
|
435
|
+
maxIterations = 60
|
|
432
436
|
): Promise<void> => {
|
|
433
437
|
let lastResult: T | undefined
|
|
434
438
|
|
|
@@ -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
|
+
}
|