@tellescope/sdk 1.256.12 → 1.256.14
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 +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/organization_signup.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
- package/lib/cjs/tests/setup_isolated.d.ts +68 -0
- package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
- package/lib/cjs/tests/setup_isolated.js +232 -0
- package/lib/cjs/tests/setup_isolated.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +260 -208
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
- package/lib/esm/enduser.d.ts +1 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +4 -2
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +1 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/organization_signup.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
- package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
- package/lib/esm/tests/setup_isolated.d.ts +68 -0
- package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
- package/lib/esm/tests/setup_isolated.js +221 -0
- package/lib/esm/tests/setup_isolated.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +260 -208
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/organization_signup.test.ts +240 -0
- package/src/tests/api_tests/out_of_office_endpoint.test.ts +162 -0
- package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
- package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
- package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
- package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
- package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
- package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
- package/src/tests/setup_isolated.ts +170 -0
- package/src/tests/tests.ts +27 -0
- package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
- package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
assert_checks,
|
|
6
|
+
Check,
|
|
7
|
+
setup_isolated_tenant,
|
|
8
|
+
} from "../setup_isolated"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
|
|
12
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Coverage for `tickets.bulk_assign` when a `queueId` is supplied — the bulk equivalent of
|
|
16
|
+
* `tickets.assign_from_queue`, added so a Ticket Queue's tickets can be checkbox-selected and handed
|
|
17
|
+
* to one user in a single call instead of one `assign_from_queue` round trip per ticket.
|
|
18
|
+
*
|
|
19
|
+
* Without `queueId` the endpoint only does `$set: { owner }`. That is insufficient for a QUEUED
|
|
20
|
+
* ticket: it would stay in the queue (still pullable by someone else), carry no dequeue provenance,
|
|
21
|
+
* and leave `ticket_queues.count` drifting upward forever. The queue branch therefore also unsets
|
|
22
|
+
* `queueId`, sets `dequeuedAt`/`dequeuedFrom`, and decrements the queue's count — the same
|
|
23
|
+
* bookkeeping `assign_from_queue` does for a single ticket. A/B/C below pin each of those.
|
|
24
|
+
*
|
|
25
|
+
* D is the check that actually justifies the handler's shape. The tickets model declares
|
|
26
|
+
* `constraints.access: [{ type: 'filter', field: 'owner' }]`, which the DB layer turns into
|
|
27
|
+
* `owner === self OR creator === self` for any non-`All` access. A queued ticket has no owner, and
|
|
28
|
+
* was typically created by someone else, so a non-admin queue manager's `DB.tickets.updateMany`
|
|
29
|
+
* matches NOTHING — the call would return 200 having assigned zero tickets, silently. The handler
|
|
30
|
+
* uses `buildOrganizationWideQueries` to get past that, exactly as `assign_from_queue` does, and
|
|
31
|
+
* re-adds the queue-membership check that those queries bypass. D1 proves the escape hatch works for
|
|
32
|
+
* a legitimate queue member; D2 proves it is still gated, and is the check that fails if someone
|
|
33
|
+
* later drops the membership check while keeping the organization-wide queries.
|
|
34
|
+
*
|
|
35
|
+
* A provisioned non-admin gets `tickets: ASSIGNED_AND_DEFAULT_ACCESS` (read/update = 'Assigned'),
|
|
36
|
+
* which is what makes D meaningful. The D tickets deliberately have NO enduserId, so the 'Assigned'
|
|
37
|
+
* role filter's other branch (enduserId in assignedEnduserIds) cannot match either — the ONLY way
|
|
38
|
+
* they can be assigned is through the organization-wide path.
|
|
39
|
+
*
|
|
40
|
+
* E is the regression guard on the pre-existing no-`queueId` behavior, which must be unchanged.
|
|
41
|
+
*
|
|
42
|
+
* Cross-tenant (F) is covered because `buildOrganizationWideQueries` is bound to the caller's
|
|
43
|
+
* businessId: another tenant's queue is invisible even to an admin, who skips the membership check.
|
|
44
|
+
*
|
|
45
|
+
* `ticket_queues.count` is maintained by asynchronous side effects (increment on ticket create) and
|
|
46
|
+
* the decrement here is deliberately fire-and-forget, so every count read polls.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
// count is maintained by async side effects; give it a moment to settle before reading
|
|
50
|
+
const poll_for = async <T,>(get: () => Promise<T>, done: (v: T) => boolean, attempts=20): Promise<T> => {
|
|
51
|
+
let value = await get()
|
|
52
|
+
for (let i = 0; i < attempts && !done(value); i++) {
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
54
|
+
value = await get()
|
|
55
|
+
}
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const tickets_bulk_assign_from_queue_checks = async (): Promise<Check[]> => {
|
|
60
|
+
const checks: Check[] = []
|
|
61
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
62
|
+
|
|
63
|
+
let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
64
|
+
let otherTenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
/* ================= fixtures ================= */
|
|
68
|
+
|
|
69
|
+
// users[1] joins the queue, users[2] deliberately does not (D2)
|
|
70
|
+
;[tenant, otherTenant] = await Promise.all([
|
|
71
|
+
setup_isolated_tenant({
|
|
72
|
+
name: `BulkAssignQueue ${RAND()}`,
|
|
73
|
+
users: [{ isAdmin: true }, { roles: ['Non-Admin'] }, { roles: ['Non-Admin'] }],
|
|
74
|
+
apiKeys: 0,
|
|
75
|
+
}),
|
|
76
|
+
setup_isolated_tenant({
|
|
77
|
+
name: `BulkAssignQueue other ${RAND()}`,
|
|
78
|
+
users: [{ isAdmin: true }],
|
|
79
|
+
apiKeys: 0,
|
|
80
|
+
}),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
const { sdk, sdkNonAdmin } = tenant
|
|
84
|
+
const queueMember = tenant.users[1]
|
|
85
|
+
const outsider = tenant.users[2]
|
|
86
|
+
|
|
87
|
+
// a session for users[2] — setup_isolated_tenant only builds sessions for users[0] and users[1]
|
|
88
|
+
const sdkOutsider = new Session({ host, authToken: outsider.authToken })
|
|
89
|
+
await sdkOutsider.refresh_session()
|
|
90
|
+
|
|
91
|
+
const owner = await sdk.api.users.createOne({
|
|
92
|
+
email: `dontsend-bulk-assign-queue-${RAND()}@tellescope.com`,
|
|
93
|
+
fname: 'BulkAssign', lname: 'QueueOwner',
|
|
94
|
+
verifiedEmail: true,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const queue = await sdk.api.ticket_queues.createOne({
|
|
98
|
+
title: `Bulk Assign Queue ${RAND()}`,
|
|
99
|
+
userIds: [owner.id, queueMember.id], // outsider is NOT a member
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const enduser = await sdk.api.endusers.createOne({ fname: 'BulkAssign', lname: 'QueuePatient' })
|
|
103
|
+
|
|
104
|
+
// A/B/C fixtures — one carries an enduser so care team behavior is exercised alongside dequeue
|
|
105
|
+
const queued1 = await sdk.api.tickets.createOne({ title: 'BAQ 1', enduserId: enduser.id, queueId: queue.id })
|
|
106
|
+
const queued2 = await sdk.api.tickets.createOne({ title: 'BAQ 2', queueId: queue.id })
|
|
107
|
+
|
|
108
|
+
const countBefore = await poll_for(
|
|
109
|
+
() => sdk.api.ticket_queues.getOne(queue.id).then(q => q.count ?? 0),
|
|
110
|
+
c => c === 2,
|
|
111
|
+
)
|
|
112
|
+
record(
|
|
113
|
+
'setup: queue count reflects both queued tickets',
|
|
114
|
+
countBefore === 2,
|
|
115
|
+
`count=${countBefore} (want 2); queue=${queue.id}`,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/* ===== A: the dequeue bookkeeping ===== */
|
|
119
|
+
|
|
120
|
+
const assignResult = await sdk.api.tickets.bulk_assign({
|
|
121
|
+
ids: [queued1.id, queued2.id], userId: owner.id, queueId: queue.id, addToCareTeam: true,
|
|
122
|
+
})
|
|
123
|
+
record(
|
|
124
|
+
'A1: reports both tickets assigned and none skipped',
|
|
125
|
+
assignResult.assigned === 2 && assignResult.skipped === 0,
|
|
126
|
+
`assigned=${assignResult.assigned} skipped=${assignResult.skipped} (want 2 / 0)`,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const after1 = await sdk.api.tickets.getOne(queued1.id)
|
|
130
|
+
record(
|
|
131
|
+
'A2: ticket left the queue with owner and dequeue provenance set',
|
|
132
|
+
!after1.queueId && after1.owner === owner.id && !!after1.dequeuedAt && after1.dequeuedFrom === queue.id,
|
|
133
|
+
`queueId=${JSON.stringify(after1.queueId)} owner=${after1.owner} dequeuedAt=${JSON.stringify(after1.dequeuedAt)} dequeuedFrom=${after1.dequeuedFrom}`,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const countAfter = await poll_for(
|
|
137
|
+
() => sdk.api.ticket_queues.getOne(queue.id).then(q => q.count ?? 0),
|
|
138
|
+
c => c === 0,
|
|
139
|
+
)
|
|
140
|
+
record(
|
|
141
|
+
'A3: queue count decremented by the number assigned',
|
|
142
|
+
countAfter === 0,
|
|
143
|
+
`count=${countAfter} (want 0, was ${countBefore})`,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
record(
|
|
147
|
+
'A4: addToCareTeam adds the new owner to the ticket enduser\'s care team',
|
|
148
|
+
!!(await sdk.api.endusers.getOne(enduser.id)).assignedTo?.includes(owner.id),
|
|
149
|
+
`assignedTo must contain ${owner.id}`,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
/* ===== B: ids no longer in the queue are skipped, not re-assigned ===== */
|
|
153
|
+
|
|
154
|
+
const replayResult = await sdk.api.tickets.bulk_assign({
|
|
155
|
+
ids: [queued1.id, queued2.id], userId: owner.id, queueId: queue.id,
|
|
156
|
+
})
|
|
157
|
+
record(
|
|
158
|
+
'B1: already-dequeued ids are reported as skipped',
|
|
159
|
+
replayResult.assigned === 0 && replayResult.skipped === 2,
|
|
160
|
+
`assigned=${replayResult.assigned} skipped=${replayResult.skipped} (want 0 / 2)`,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const countAfterReplay = await sdk.api.ticket_queues.getOne(queue.id).then(q => q.count ?? 0)
|
|
164
|
+
record(
|
|
165
|
+
'B2: queue count is not decremented again when nothing was assigned',
|
|
166
|
+
countAfterReplay === 0,
|
|
167
|
+
`count=${countAfterReplay} (want 0 — a decrement here would drive it negative)`,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
/* ===== C: the queue scope is honored ===== */
|
|
171
|
+
|
|
172
|
+
const otherQueue = await sdk.api.ticket_queues.createOne({
|
|
173
|
+
title: `Bulk Assign Other Queue ${RAND()}`,
|
|
174
|
+
userIds: [owner.id],
|
|
175
|
+
})
|
|
176
|
+
const inOtherQueue = await sdk.api.tickets.createOne({ title: 'BAQ other queue', queueId: otherQueue.id })
|
|
177
|
+
|
|
178
|
+
const wrongQueueResult = await sdk.api.tickets.bulk_assign({
|
|
179
|
+
ids: [inOtherQueue.id], userId: owner.id, queueId: queue.id,
|
|
180
|
+
})
|
|
181
|
+
const stillInOther = await sdk.api.tickets.getOne(inOtherQueue.id)
|
|
182
|
+
record(
|
|
183
|
+
'C1: a ticket in a different queue is not assigned when the wrong queueId is passed',
|
|
184
|
+
wrongQueueResult.assigned === 0 && stillInOther.queueId === otherQueue.id && !stillInOther.owner,
|
|
185
|
+
`assigned=${wrongQueueResult.assigned} (want 0); ticket queueId=${stillInOther.queueId} owner=${JSON.stringify(stillInOther.owner)}`,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
/* ===== D: the organization-wide path, and its gate ===== */
|
|
189
|
+
|
|
190
|
+
// NO enduserId: the 'Assigned' role filter cannot match these by owner, creator, or enduser,
|
|
191
|
+
// so they are assignable only through the organization-wide queries
|
|
192
|
+
const forMember1 = await sdk.api.tickets.createOne({ title: 'BAQ member 1', queueId: queue.id })
|
|
193
|
+
const forMember2 = await sdk.api.tickets.createOne({ title: 'BAQ member 2', queueId: queue.id })
|
|
194
|
+
|
|
195
|
+
const memberResult = await sdkNonAdmin.api.tickets.bulk_assign({
|
|
196
|
+
ids: [forMember1.id, forMember2.id], userId: owner.id, queueId: queue.id,
|
|
197
|
+
}).then(r => r).catch((e: any) => ({ error: e?.message || String(e) } as any))
|
|
198
|
+
|
|
199
|
+
const memberAfter = await sdk.api.tickets.getOne(forMember1.id)
|
|
200
|
+
record(
|
|
201
|
+
'D1: a non-admin queue member can bulk assign queued tickets they do not own',
|
|
202
|
+
memberResult?.assigned === 2 && !memberAfter.queueId && memberAfter.owner === owner.id,
|
|
203
|
+
`result=${JSON.stringify(memberResult)}; ticket queueId=${JSON.stringify(memberAfter.queueId)} owner=${memberAfter.owner}`
|
|
204
|
+
+ ` — 0 assigned here means the handler fell back to the owner-filtered DB`,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const forOutsider = await sdk.api.tickets.createOne({ title: 'BAQ outsider', queueId: queue.id })
|
|
208
|
+
const outsiderResult = await sdkOutsider.api.tickets.bulk_assign({
|
|
209
|
+
ids: [forOutsider.id], userId: outsider.id, queueId: queue.id,
|
|
210
|
+
}).then((r: any) => ({ resolved: r })).catch((e: any) => ({ rejected: e?.message || String(e) }))
|
|
211
|
+
|
|
212
|
+
const outsiderAfter = await sdk.api.tickets.getOne(forOutsider.id)
|
|
213
|
+
record(
|
|
214
|
+
'D2: a non-admin who is not a member of the queue is rejected',
|
|
215
|
+
!!(outsiderResult as any).rejected && outsiderAfter.queueId === queue.id && !outsiderAfter.owner,
|
|
216
|
+
`result=${JSON.stringify(outsiderResult)}; ticket queueId=${outsiderAfter.queueId} owner=${JSON.stringify(outsiderAfter.owner)}`,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
/* ===== E: regression guard — the no-queueId path is unchanged ===== */
|
|
220
|
+
|
|
221
|
+
const stayQueued = await sdk.api.tickets.createOne({ title: 'BAQ not dequeued', queueId: queue.id })
|
|
222
|
+
const legacyResult = await sdk.api.tickets.bulk_assign({ ids: [stayQueued.id], userId: owner.id })
|
|
223
|
+
const stayedAfter = await sdk.api.tickets.getOne(stayQueued.id)
|
|
224
|
+
record(
|
|
225
|
+
'E1: without queueId the ticket is assigned but NOT dequeued',
|
|
226
|
+
legacyResult.assigned === 1 && stayedAfter.owner === owner.id
|
|
227
|
+
&& stayedAfter.queueId === queue.id && !stayedAfter.dequeuedAt,
|
|
228
|
+
`assigned=${legacyResult.assigned}; owner=${stayedAfter.owner} queueId=${stayedAfter.queueId} dequeuedAt=${JSON.stringify(stayedAfter.dequeuedAt)}`,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// a ticket already owned by the target user is still "assigned", not counted as skipped
|
|
232
|
+
const repeatResult = await sdk.api.tickets.bulk_assign({ ids: [stayQueued.id], userId: owner.id })
|
|
233
|
+
record(
|
|
234
|
+
'E2: re-assigning to the same owner is not reported as skipped',
|
|
235
|
+
repeatResult.assigned === 1 && repeatResult.skipped === 0,
|
|
236
|
+
`assigned=${repeatResult.assigned} skipped=${repeatResult.skipped} (want 1 / 0 — matchedCount, not modifiedCount)`,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
/* ===== F: another tenant's queue is invisible, even to an admin ===== */
|
|
240
|
+
|
|
241
|
+
const foreignResult = await otherTenant.sdk.api.tickets.bulk_assign({
|
|
242
|
+
ids: [forOutsider.id], userId: otherTenant.users[0].id, queueId: queue.id,
|
|
243
|
+
}).then((r: any) => ({ resolved: r })).catch((e: any) => ({ rejected: e?.message || String(e) }))
|
|
244
|
+
|
|
245
|
+
const foreignAfter = await sdk.api.tickets.getOne(forOutsider.id)
|
|
246
|
+
record(
|
|
247
|
+
'F1: another tenant cannot assign tickets out of this tenant\'s queue',
|
|
248
|
+
(foreignAfter.queueId === queue.id && !foreignAfter.owner)
|
|
249
|
+
&& ((foreignResult as any).rejected || (foreignResult as any).resolved?.assigned === 0),
|
|
250
|
+
`result=${JSON.stringify(foreignResult)}; ticket queueId=${foreignAfter.queueId} owner=${JSON.stringify(foreignAfter.owner)}`,
|
|
251
|
+
)
|
|
252
|
+
} finally {
|
|
253
|
+
// Teardown BEFORE anything asserts — assert calls process.exit, which would strand these tenants.
|
|
254
|
+
for (const t of [tenant, otherTenant]) {
|
|
255
|
+
if (t) { try { await t.teardown() } catch (e) { console.error('teardown failed', e) } }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return checks
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Asserting wrapper, for standalone runs and for sequential use in the suite. */
|
|
263
|
+
export const tickets_bulk_assign_from_queue_tests = async () => {
|
|
264
|
+
assert_checks('Tickets bulk assign from queue', await tickets_bulk_assign_from_queue_checks())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Allow running this test file independently
|
|
268
|
+
if (require.main === module) {
|
|
269
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
270
|
+
tickets_bulk_assign_from_queue_tests()
|
|
271
|
+
.then(() => {
|
|
272
|
+
console.log("✅ Tickets bulk assign from queue test suite completed successfully")
|
|
273
|
+
process.exit(0)
|
|
274
|
+
})
|
|
275
|
+
.catch((error) => {
|
|
276
|
+
console.error("❌ Tickets bulk assign from queue test suite failed:", error)
|
|
277
|
+
process.exit(1)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import axios from "axios"
|
|
2
|
+
|
|
3
|
+
import { assert, log_header } from "@tellescope/testing"
|
|
4
|
+
|
|
5
|
+
import { Session } from "../sdk"
|
|
6
|
+
|
|
7
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provisions a throwaway tenant for a test, via the API's /test-support endpoints.
|
|
11
|
+
*
|
|
12
|
+
* Why this exists alongside setup.ts: setup_tests authenticates two hand-seeded users named by
|
|
13
|
+
* TEST_EMAIL / NON_ADMIN_EMAIL against a hardcoded businessId, so running the suite requires a copy
|
|
14
|
+
* of someone's local database, and every test shares one tenant. This helper needs **no environment
|
|
15
|
+
* configuration at all** against a local API — clone, empty Mongo, run.
|
|
16
|
+
*
|
|
17
|
+
* setup.ts is deliberately untouched; existing tests keep the shared org. Use this for new tests.
|
|
18
|
+
*
|
|
19
|
+
* Isolated tests must NOT call sdk.reset_db(): it clears six global throttle namespaces, so it
|
|
20
|
+
* disturbs the rate-limit state of any concurrently running suite even though record data is
|
|
21
|
+
* tenant-scoped.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TEST_SUPPORT_HEADER = 'x-tellescope-test-support'
|
|
25
|
+
|
|
26
|
+
export type IsolatedUser = {
|
|
27
|
+
id: string,
|
|
28
|
+
email: string,
|
|
29
|
+
password: string,
|
|
30
|
+
authToken: string,
|
|
31
|
+
roles: string[],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type IsolatedTenant = {
|
|
35
|
+
businessId: string,
|
|
36
|
+
subdomain: string,
|
|
37
|
+
users: IsolatedUser[],
|
|
38
|
+
apiKeys: string[],
|
|
39
|
+
roles: string[],
|
|
40
|
+
/** Admin session (users[0]) */
|
|
41
|
+
sdk: Session,
|
|
42
|
+
/** Non-admin session (users[1]), when the spec provisioned one */
|
|
43
|
+
sdkNonAdmin: Session,
|
|
44
|
+
teardown: () => Promise<void>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type IsolatedTenantSpec = {
|
|
48
|
+
name?: string,
|
|
49
|
+
users?: { roles?: string[], isAdmin?: boolean }[],
|
|
50
|
+
apiKeys?: number,
|
|
51
|
+
roles?: { role: string, permissions?: any }[],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const post = async (path: string, body: object) => {
|
|
55
|
+
const r = await axios.request({
|
|
56
|
+
method: 'post',
|
|
57
|
+
url: `${host}/test-support${path}`,
|
|
58
|
+
// forwarded only if present — required on staging, unnecessary (and unset) locally
|
|
59
|
+
data: { ...body, ...(process.env.SERVICES_SECRET ? { servicesSecret: process.env.SERVICES_SECRET } : {}) },
|
|
60
|
+
headers: { [TEST_SUPPORT_HEADER]: '1' },
|
|
61
|
+
validateStatus: () => true,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (r.status !== 200) {
|
|
65
|
+
// Surface the server's reason. setup.ts's bare process.exit() on missing credentials turns a
|
|
66
|
+
// misconfiguration into a silent green pass; a provisioning failure must be loud.
|
|
67
|
+
throw new Error(
|
|
68
|
+
`test-support ${path} failed (${r.status}): ${JSON.stringify(r.data)}`
|
|
69
|
+
+ (r.status === 404
|
|
70
|
+
? `\nThe route is not mounted — LOCAL is probably not "true" in the API's environment.`
|
|
71
|
+
: '')
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return r.data
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const setup_isolated_tenant = async (spec: IsolatedTenantSpec = {}): Promise<IsolatedTenant> => {
|
|
78
|
+
const tenant = await post('/provision-tenant', spec)
|
|
79
|
+
|
|
80
|
+
const sessionFor = async (user?: IsolatedUser) => {
|
|
81
|
+
if (!user) return new Session({ host })
|
|
82
|
+
const session = new Session({ host, authToken: user.authToken })
|
|
83
|
+
await session.refresh_session()
|
|
84
|
+
return session
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [sdk, sdkNonAdmin] = await Promise.all([
|
|
88
|
+
sessionFor(tenant.users[0]),
|
|
89
|
+
sessionFor(tenant.users[1]),
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
...tenant,
|
|
94
|
+
sdk,
|
|
95
|
+
sdkNonAdmin,
|
|
96
|
+
teardown: async () => { await post('/teardown-tenant', { businessId: tenant.businessId }) },
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sets the pending email-verification code for an unverified user, so a test can drive the real
|
|
101
|
+
* submit_email_verification endpoint. The address must be a `dontsend...@tellescope.com` one. */
|
|
102
|
+
export const set_email_verification_code = async (email: string, code: string) => (
|
|
103
|
+
post('/set-email-verification-code', { email, code })
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
/** Raw access, for tests that need to assert on the response of a rejected call. */
|
|
107
|
+
export const test_support_request = async (path: string, body: object) => {
|
|
108
|
+
const r = await axios.request({
|
|
109
|
+
method: 'post',
|
|
110
|
+
url: `${host}/test-support${path}`,
|
|
111
|
+
data: { ...body, ...(process.env.SERVICES_SECRET ? { servicesSecret: process.env.SERVICES_SECRET } : {}) },
|
|
112
|
+
headers: { [TEST_SUPPORT_HEADER]: '1' },
|
|
113
|
+
validateStatus: () => true,
|
|
114
|
+
})
|
|
115
|
+
return { status: r.status, body: r.data }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ============================ isolated-test result plumbing ============================ */
|
|
119
|
+
|
|
120
|
+
export type Check = { label: string, pass: boolean, detail: string }
|
|
121
|
+
|
|
122
|
+
/** Prints a suite's evidence table, then asserts. Split out so the assert phase can be deferred until
|
|
123
|
+
* after every concurrent suite has torn down — see run_isolated_in_parallel. */
|
|
124
|
+
export const assert_checks = (name: string, checks: Check[]) => {
|
|
125
|
+
console.log(`\n ${name} check summary:`)
|
|
126
|
+
for (const c of checks) {
|
|
127
|
+
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
128
|
+
}
|
|
129
|
+
console.log('')
|
|
130
|
+
|
|
131
|
+
for (const c of checks) {
|
|
132
|
+
assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Runs isolated test bodies CONCURRENTLY.
|
|
138
|
+
*
|
|
139
|
+
* Why this exists rather than a bare Promise.all: the harness's `assert` calls process.exit(1) on
|
|
140
|
+
* failure, and process.exit does not run pending `finally` blocks. So if one suite asserted while
|
|
141
|
+
* another was still in flight, the other's teardown would never run and its provisioned tenants would
|
|
142
|
+
* be stranded permanently (nothing in the system can delete an organization record). `assert` also
|
|
143
|
+
* exits rather than throwing, so Promise.allSettled cannot recover it either.
|
|
144
|
+
*
|
|
145
|
+
* The contract that makes concurrency safe: each `run` collects Check[] and tears down its OWN tenants
|
|
146
|
+
* in a finally, and never asserts. Nothing here asserts until every suite has settled.
|
|
147
|
+
*
|
|
148
|
+
* Each suite's table prints as one non-yielding burst, so concurrent output stays readable.
|
|
149
|
+
*/
|
|
150
|
+
export const run_isolated_in_parallel = async (
|
|
151
|
+
suites: { name: string, run: () => Promise<Check[]> }[],
|
|
152
|
+
) => {
|
|
153
|
+
log_header(`Isolated (parallel): ${suites.map(s => s.name).join(' | ')}`)
|
|
154
|
+
|
|
155
|
+
const settled = await Promise.allSettled(suites.map(s => s.run()))
|
|
156
|
+
|
|
157
|
+
// Surface an infrastructure failure (a throw, not a failed check) before asserting anything, so the
|
|
158
|
+
// reason isn't buried under another suite's table.
|
|
159
|
+
const rejected = settled.flatMap((r, i) => r.status === 'rejected' ? [{ name: suites[i].name, reason: r.reason }] : [])
|
|
160
|
+
for (const { name, reason } of rejected) {
|
|
161
|
+
console.error(` ${name} threw before producing checks:`, reason)
|
|
162
|
+
}
|
|
163
|
+
if (rejected.length) throw rejected[0].reason
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < settled.length; i++) {
|
|
166
|
+
const r = settled[i]
|
|
167
|
+
if (r.status !== 'fulfilled') continue
|
|
168
|
+
assert_checks(suites[i].name, r.value)
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -103,6 +103,7 @@ import { outbound_chat_sent_trigger_tests } from "./api_tests/outbound_chat_sent
|
|
|
103
103
|
import { time_tracks_tests, time_tracks_historical_tests, time_tracks_correction_tests, time_tracks_review_tests, time_tracks_lock_tests, time_tracks_edge_case_tests, time_tracks_resubmit_tests, time_tracks_appointment_duration_tests } from "./api_tests/time_tracks.test";
|
|
104
104
|
import { monthly_availability_restrictions_tests } from "./api_tests/monthly_availability_restrictions.test";
|
|
105
105
|
import { calendar_event_limits_tests } from "./api_tests/calendar_event_limits.test";
|
|
106
|
+
import { out_of_office_endpoint_tests } from "./api_tests/out_of_office_endpoint.test";
|
|
106
107
|
import { appointment_link_expiration_tests } from "./api_tests/appointment_link_expiration.test";
|
|
107
108
|
import { custom_aggregation_tests } from "./api_tests/custom_aggregation.test";
|
|
108
109
|
import { chats_analytics_tests } from "./api_tests/chats_analytics.test";
|
|
@@ -134,6 +135,10 @@ import { proxy_image_ssrf_tests } from "./api_tests/security/F-0025-proxy-image-
|
|
|
134
135
|
import { webhook_outbound_ssrf_tests } from "./api_tests/security/F-0015-webhook-outbound-ssrf.test";
|
|
135
136
|
import { public_endpoint_auth_field_lock_tests } from "./api_tests/security/public_endpoint_auth_field_lock.test";
|
|
136
137
|
import { formsort_webhook_auth_field_lock_tests } from "./api_tests/security/formsort_webhook_auth_field_lock.test";
|
|
138
|
+
import { booking_info_idor_tests } from "./api_tests/security/F-0179-booking-info-idor-token-mint.test";
|
|
139
|
+
import { automated_actions_process_gate_tests } from "./api_tests/security/F-0192-automated-actions-process-gate.test";
|
|
140
|
+
import { schema_adminonly_enforcement_tests } from "./api_tests/security/F-0191-schema-adminonly-enforcement.test";
|
|
141
|
+
import { startlinktoken_escalation_tests } from "./api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test";
|
|
137
142
|
import { bulk_assignment_tests } from "./api_tests/bulk_assignment.test";
|
|
138
143
|
import { tickets_bulk_assign_care_team_tests } from "./api_tests/tickets_bulk_assign_care_team.test";
|
|
139
144
|
import { managed_content_enduser_access_tests } from "./api_tests/managed_content_enduser_access.test";
|
|
@@ -151,6 +156,12 @@ import { dont_sync_to_elation_form_submission_tests } from "./api_tests/dont_syn
|
|
|
151
156
|
import { medication_added_trigger_tests } from "./api_tests/medication_added_trigger.test";
|
|
152
157
|
import { conditional_logic_medication_unit_tests } from "./unit_tests/conditional_logic_medication.test";
|
|
153
158
|
import { phone_validation_unit_tests } from "./unit_tests/phone_validation.test";
|
|
159
|
+
import { openapi_spec_freshness_unit_tests } from "./unit_tests/openapi_spec_freshness.test";
|
|
160
|
+
import { do_not_send_address_unit_tests } from "./unit_tests/do_not_send_address.test";
|
|
161
|
+
import { organization_signup_checks } from "./api_tests/organization_signup.test";
|
|
162
|
+
import { add_to_journey_foreign_enduserid_checks } from "./api_tests/security/F-0196-add-to-journey-foreign-enduserid.test";
|
|
163
|
+
import { tickets_bulk_assign_from_queue_checks } from "./api_tests/tickets_bulk_assign_from_queue.test";
|
|
164
|
+
import { run_isolated_in_parallel } from "./setup_isolated";
|
|
154
165
|
import { elation_user_id_tests } from "./api_tests/elation_user_id.test";
|
|
155
166
|
import { organization_settings_duplicates_tests } from "./api_tests/organization_settings_duplicates.test";
|
|
156
167
|
import { stedi_approval_tests } from "./api_tests/stedi_approval.test";
|
|
@@ -15362,11 +15373,22 @@ const ip_address_form_tests = async () => {
|
|
|
15362
15373
|
await enduser_conditional_logic_tests()
|
|
15363
15374
|
await conditional_logic_medication_unit_tests()
|
|
15364
15375
|
await phone_validation_unit_tests()
|
|
15376
|
+
await openapi_spec_freshness_unit_tests()
|
|
15377
|
+
await do_not_send_address_unit_tests()
|
|
15365
15378
|
await replace_enduser_template_values_tests()
|
|
15366
15379
|
await safe_json_parse_template_tests()
|
|
15367
15380
|
await replace_form_field_template_values_tests()
|
|
15368
15381
|
await mfa_tests()
|
|
15369
15382
|
await setup_tests(sdk, sdkNonAdmin)
|
|
15383
|
+
// Isolated, tenant-provisioned suites run first and CONCURRENTLY: each provisions its own
|
|
15384
|
+
// throwaway tenant via /test-support, so they cannot collide with each other or with the
|
|
15385
|
+
// shared fixture. Both collect Check[] and tear down before anything asserts — assert calls
|
|
15386
|
+
// process.exit, which would otherwise strand the other suite's tenants.
|
|
15387
|
+
await run_isolated_in_parallel([
|
|
15388
|
+
{ name: 'Organization signup', run: organization_signup_checks },
|
|
15389
|
+
{ name: 'F-0196 add_to_journey foreign enduserId', run: add_to_journey_foreign_enduserid_checks },
|
|
15390
|
+
{ name: 'Tickets bulk assign from queue', run: tickets_bulk_assign_from_queue_checks },
|
|
15391
|
+
])
|
|
15370
15392
|
await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
|
|
15371
15393
|
await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
|
|
15372
15394
|
await webhook_outbound_ssrf_tests({ sdk, sdkNonAdmin })
|
|
@@ -15386,6 +15408,10 @@ const ip_address_form_tests = async () => {
|
|
|
15386
15408
|
await enduser_write_restrictions_tests({ sdk, sdkNonAdmin })
|
|
15387
15409
|
await enduser_ai_summary_trust_gate_tests({ sdk, sdkNonAdmin })
|
|
15388
15410
|
await enduser_cross_create_tests({ sdk, sdkNonAdmin })
|
|
15411
|
+
await booking_info_idor_tests({ sdk, sdkNonAdmin })
|
|
15412
|
+
await automated_actions_process_gate_tests({ sdk, sdkNonAdmin })
|
|
15413
|
+
await schema_adminonly_enforcement_tests({ sdk, sdkNonAdmin })
|
|
15414
|
+
await startlinktoken_escalation_tests({ sdk, sdkNonAdmin })
|
|
15389
15415
|
await globalunique_leak_tests({ sdk, sdkNonAdmin })
|
|
15390
15416
|
await load_inbox_redaction_tests({ sdk, sdkNonAdmin })
|
|
15391
15417
|
await suggested_reply_redaction_tests({ sdk, sdkNonAdmin })
|
|
@@ -15446,6 +15472,7 @@ const ip_address_form_tests = async () => {
|
|
|
15446
15472
|
await time_tracks_resubmit_tests({ sdk, sdkNonAdmin })
|
|
15447
15473
|
await time_tracks_appointment_duration_tests({ sdk, sdkNonAdmin })
|
|
15448
15474
|
await calendar_event_limits_tests({ sdk, sdkNonAdmin })
|
|
15475
|
+
await out_of_office_endpoint_tests({ sdk, sdkNonAdmin })
|
|
15449
15476
|
await appointment_link_expiration_tests({ sdk, sdkNonAdmin })
|
|
15450
15477
|
await get_some_projection_tests({ sdk, sdkNonAdmin })
|
|
15451
15478
|
await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assert,
|
|
5
|
+
log_header,
|
|
6
|
+
} from "@tellescope/testing"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The email-suppression predicate from packages/private/api/api/modules/sqs.ts.
|
|
10
|
+
*
|
|
11
|
+
* Mirrored here rather than imported: the API is a private package the SDK does not depend on. The
|
|
12
|
+
* point of the test is to pin the CONTRACT — prefix AND domain both required — because the guard is
|
|
13
|
+
* otherwise unobservable from a test (a suppressed message simply never reaches SQS), and because
|
|
14
|
+
* loosening it in either direction would start suppressing real mail or start sending test mail.
|
|
15
|
+
*
|
|
16
|
+
* If this drifts from sqs.ts, that is the signal to look at why.
|
|
17
|
+
*/
|
|
18
|
+
const is_do_not_send_address = (emailAddress?: string) => (
|
|
19
|
+
!!emailAddress && emailAddress.endsWith('@tellescope.com') && emailAddress.startsWith('dontsend')
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const do_not_send_address_unit_tests = async () => {
|
|
23
|
+
log_header("Do-not-send address predicate")
|
|
24
|
+
|
|
25
|
+
const cases: [string | undefined, boolean, string][] = [
|
|
26
|
+
['dontsend@tellescope.com', true, 'exact prefix and domain'],
|
|
27
|
+
['dontsend-signup-ab12@tellescope.com', true, 'suffixed local part (what provisioning generates)'],
|
|
28
|
+
['dontsend3@tellescope.com', true, 'numbered variant already used by the suite'],
|
|
29
|
+
|
|
30
|
+
// near misses — both halves are load-bearing
|
|
31
|
+
['dontsend@example.com', false, 'right prefix, wrong domain: must NOT suppress'],
|
|
32
|
+
['realuser@tellescope.com', false, 'right domain, wrong prefix: must NOT suppress'],
|
|
33
|
+
['notdontsend@tellescope.com', false, 'prefix must be at the START, not anywhere'],
|
|
34
|
+
['dontsend@tellescope.com.evil.com', false, 'domain must be a suffix, not a substring'],
|
|
35
|
+
|
|
36
|
+
[undefined, false, 'undefined is not suppressed (and must not throw)'],
|
|
37
|
+
['', false, 'empty string is not suppressed'],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for (const [input, expected, why] of cases) {
|
|
41
|
+
assert(
|
|
42
|
+
is_do_not_send_address(input) === expected,
|
|
43
|
+
`${JSON.stringify(input)} should be ${expected ? 'suppressed' : 'sent'} — ${why}`,
|
|
44
|
+
`${JSON.stringify(input)} -> ${expected ? 'suppressed' : 'sent'} (${why})`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Allow running this test file independently
|
|
50
|
+
if (require.main === module) {
|
|
51
|
+
do_not_send_address_unit_tests()
|
|
52
|
+
.then(() => {
|
|
53
|
+
console.log("✅ Do-not-send address predicate tests completed successfully")
|
|
54
|
+
process.exit(0)
|
|
55
|
+
})
|
|
56
|
+
.catch((error) => {
|
|
57
|
+
console.error("❌ Do-not-send address predicate tests failed:", error)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import * as fs from "fs"
|
|
4
|
+
import * as os from "os"
|
|
5
|
+
import * as path from "path"
|
|
6
|
+
import { execFileSync } from "child_process"
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
assert,
|
|
10
|
+
log_header,
|
|
11
|
+
} from "@tellescope/testing"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Guard: packages/public/schema/openapi.json must match what the generator currently produces.
|
|
15
|
+
*
|
|
16
|
+
* The spec is git-tracked AND published inside @tellescope/schema (no `files` field, and the
|
|
17
|
+
* .npmignore covers only set_vars.sh), but nothing in the build or CI regenerates it. It had
|
|
18
|
+
* therefore drifted several commits behind schema.ts — which mattered because `adminOnly` is
|
|
19
|
+
* rendered into the spec as an "**Admin only.**" description prefix, so the published docs were
|
|
20
|
+
* advertising gates that had changed. That drift is the doc half of F-0191; the runtime half was
|
|
21
|
+
* fixed by making getCustomEndpointFields fall back to the schema flag.
|
|
22
|
+
*
|
|
23
|
+
* The generator is byte-deterministic (date `getExample`s are pinned in validation.ts precisely so
|
|
24
|
+
* this comparison is possible), so an exact match is a fair assertion.
|
|
25
|
+
*
|
|
26
|
+
* If this fails: cd packages/public/schema && node lib/cjs/generate-openapi.js ./openapi.json
|
|
27
|
+
*/
|
|
28
|
+
export const openapi_spec_freshness_unit_tests = async () => {
|
|
29
|
+
log_header("OpenAPI spec freshness")
|
|
30
|
+
|
|
31
|
+
// resolve the schema package from its manifest, so this works regardless of hoisting
|
|
32
|
+
const schemaPkgJson = require.resolve('@tellescope/schema/package.json')
|
|
33
|
+
const schemaRoot = path.dirname(schemaPkgJson)
|
|
34
|
+
const generator = path.join(schemaRoot, 'lib', 'cjs', 'generate-openapi.js')
|
|
35
|
+
const committed = path.join(schemaRoot, 'openapi.json')
|
|
36
|
+
|
|
37
|
+
// Don't fail the suite for a stale/absent build — that is a build problem, not spec drift.
|
|
38
|
+
if (!fs.existsSync(generator)) {
|
|
39
|
+
console.log(` SKIPPED: generator not built at ${generator} (run ./build_cjs.sh)`)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
assert(fs.existsSync(committed), `openapi.json missing at ${committed}`, 'openapi.json is present')
|
|
43
|
+
|
|
44
|
+
const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-freshness-')), 'openapi.json')
|
|
45
|
+
try {
|
|
46
|
+
execFileSync('node', [generator, tmp], { stdio: 'pipe' })
|
|
47
|
+
|
|
48
|
+
const regenerated = fs.readFileSync(tmp, 'utf8')
|
|
49
|
+
const onDisk = fs.readFileSync(committed, 'utf8')
|
|
50
|
+
|
|
51
|
+
if (regenerated !== onDisk) {
|
|
52
|
+
// a line count is far more useful than a 6000-line dump
|
|
53
|
+
const a = regenerated.split('\n')
|
|
54
|
+
const b = onDisk.split('\n')
|
|
55
|
+
const firstDiff = a.findIndex((line, i) => line !== b[i])
|
|
56
|
+
console.log(
|
|
57
|
+
` regenerated ${a.length} lines vs committed ${b.length} lines;`
|
|
58
|
+
+ ` first difference at line ${firstDiff + 1}`
|
|
59
|
+
)
|
|
60
|
+
if (firstDiff >= 0) {
|
|
61
|
+
console.log(` committed: ${(b[firstDiff] ?? '<missing>').trim().slice(0, 160)}`)
|
|
62
|
+
console.log(` regenerated: ${(a[firstDiff] ?? '<missing>').trim().slice(0, 160)}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assert(
|
|
67
|
+
regenerated === onDisk,
|
|
68
|
+
'openapi.json is stale — regenerate it: '
|
|
69
|
+
+ 'cd packages/public/schema && node lib/cjs/generate-openapi.js ./openapi.json',
|
|
70
|
+
'openapi.json matches the generator output',
|
|
71
|
+
)
|
|
72
|
+
} finally {
|
|
73
|
+
try { fs.rmSync(path.dirname(tmp), { recursive: true, force: true }) } catch {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Allow running this test file independently
|
|
78
|
+
if (require.main === module) {
|
|
79
|
+
openapi_spec_freshness_unit_tests()
|
|
80
|
+
.then(() => {
|
|
81
|
+
console.log("✅ OpenAPI spec freshness test completed successfully")
|
|
82
|
+
process.exit(0)
|
|
83
|
+
})
|
|
84
|
+
.catch((error) => {
|
|
85
|
+
console.error("❌ OpenAPI spec freshness test failed:", error)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
})
|
|
88
|
+
}
|
package/test_generated.pdf
CHANGED
|
Binary file
|