Skip to content

Commit 5e801b5

Browse files
committed
fix(sailpoint): complete integration validation
1 parent beef0ea commit 5e801b5

24 files changed

Lines changed: 2618 additions & 251 deletions

File tree

apps/docs/content/docs/integrations/sailpoint.mdx

Lines changed: 958 additions & 70 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/sailpoint.ts

Lines changed: 95 additions & 28 deletions
Large diffs are not rendered by default.

apps/sim/lib/internal/sailpoint/client.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
clearSailPointTokenStateForTests,
77
getSailPointAccessToken,
88
getSailPointTokenStateForTests,
9+
readTotalCount,
910
resolveSailPointHosts,
1011
sailpointFetch,
1112
} from '@/lib/internal/sailpoint/client'
@@ -65,6 +66,27 @@ describe('SailPoint client', () => {
6566
await expect(Promise.all([first, second])).resolves.toEqual(['shared', 'shared'])
6667
})
6768

69+
it('lets one token waiter abort without cancelling the shared exchange', async () => {
70+
let release: ((response: Response) => void) | undefined
71+
mockFetch.mockImplementationOnce(
72+
() =>
73+
new Promise<Response>((resolve) => {
74+
release = resolve
75+
})
76+
)
77+
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
78+
const controller = new AbortController()
79+
const first = getSailPointAccessToken(credentials, controller.signal)
80+
const second = getSailPointAccessToken(credentials)
81+
82+
controller.abort(new Error('caller stopped'))
83+
await expect(first).rejects.toThrow('caller stopped')
84+
release?.(tokenResponse('shared'))
85+
await expect(second).resolves.toBe('shared')
86+
expect(mockFetch).toHaveBeenCalledTimes(1)
87+
expect(getSailPointTokenStateForTests().exchangeSize).toBe(0)
88+
})
89+
6890
it('expires cached tokens before their provider expiry', async () => {
6991
vi.useFakeTimers()
7092
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
@@ -106,4 +128,46 @@ describe('SailPoint client', () => {
106128
}))
107129
).rejects.toThrow(/maximum|limit|exceeds/i)
108130
})
131+
132+
it('aborts during rate-limit backoff without another provider call', async () => {
133+
mockFetch
134+
.mockResolvedValueOnce(tokenResponse('token'))
135+
.mockResolvedValueOnce(new Response(null, { status: 429, headers: { 'retry-after': '30' } }))
136+
const controller = new AbortController()
137+
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
138+
const pending = sailpointFetch(
139+
credentials,
140+
(hosts) => ({
141+
url: `${hosts.apiBaseUrl}/identities/v1`,
142+
init: { method: 'GET' },
143+
}),
144+
{ signal: controller.signal }
145+
)
146+
147+
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
148+
controller.abort(new Error('stop retrying'))
149+
await expect(pending).rejects.toThrow('stop retrying')
150+
expect(mockFetch).toHaveBeenCalledTimes(2)
151+
})
152+
153+
it('rejects redirects for token and authenticated provider requests', async () => {
154+
mockFetch
155+
.mockResolvedValueOnce(tokenResponse('token'))
156+
.mockResolvedValueOnce(Response.json({ id: 'identity' }))
157+
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
158+
159+
await sailpointFetch(credentials, (hosts) => ({
160+
url: `${hosts.apiBaseUrl}/identities/v1/id`,
161+
init: { method: 'GET' },
162+
}))
163+
164+
expect(mockFetch.mock.calls[0][1]?.redirect).toBe('error')
165+
expect(mockFetch.mock.calls[1][1]?.redirect).toBe('error')
166+
})
167+
168+
it('accepts only non-negative integer total counts', () => {
169+
expect(readTotalCount(new Headers({ 'x-total-count': '7' }))).toBe(7)
170+
expect(readTotalCount(new Headers({ 'x-total-count': '1.5' }))).toBeNull()
171+
expect(readTotalCount(new Headers({ 'x-total-count': '-1' }))).toBeNull()
172+
})
109173
})

apps/sim/lib/internal/sailpoint/client.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto'
2-
import { sleep } from '@sim/utils/helpers'
2+
import { interruptibleSleep } from '@sim/utils/helpers'
33
import { isRecordLike } from '@sim/utils/object'
44
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
55
import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server'
@@ -37,10 +37,23 @@ const MAX_FETCH_RETRIES = 4
3737
const MAX_TOKEN_CACHE_ENTRIES = 100
3838
const MAX_TOKEN_EXCHANGES = 100
3939
const MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024
40+
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
4041
const TOKEN_EXPIRY_BUFFER_MS = 60_000
4142
const tokenCache = new Map<string, CachedToken>()
4243
const tokenExchanges = new Map<string, Promise<string>>()
4344

45+
async function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
46+
if (!signal) return promise
47+
signal.throwIfAborted()
48+
49+
return new Promise<T>((resolve, reject) => {
50+
const onAbort = () =>
51+
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'))
52+
signal.addEventListener('abort', onAbort, { once: true })
53+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
54+
})
55+
}
56+
4457
export function resolveSailPointHosts(tenant: string): SailPointHosts {
4558
let host = tenant.trim().replace(/^https?:\/\//i, '')
4659
host = host
@@ -155,14 +168,16 @@ async function exchangeAccessToken(
155168
client_secret: credentials.clientSecret,
156169
}).toString(),
157170
cache: 'no-store',
171+
redirect: 'error',
158172
signal,
159173
})
160174

161175
if (response.status === 429 && attempt < MAX_FETCH_RETRIES) {
162176
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
163177
await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES)
164178
attempt += 1
165-
await sleep(backoffWithJitter(attempt, retryAfterMs))
179+
await interruptibleSleep(backoffWithJitter(attempt, retryAfterMs), signal)
180+
signal?.throwIfAborted()
166181
continue
167182
}
168183

@@ -210,16 +225,19 @@ export async function getSailPointAccessToken(
210225
if (cached) tokenCache.delete(key)
211226

212227
const existing = tokenExchanges.get(key)
213-
if (existing) return existing
228+
if (existing) return waitForPromiseWithSignal(existing, signal)
214229
if (tokenExchanges.size >= MAX_TOKEN_EXCHANGES) {
215230
throw new Error('Too many concurrent SailPoint token exchanges')
216231
}
217232

218-
const exchange = exchangeAccessToken(credentials, signal).finally(() => {
233+
const exchange = exchangeAccessToken(
234+
credentials,
235+
AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
236+
).finally(() => {
219237
tokenExchanges.delete(key)
220238
})
221239
tokenExchanges.set(key, exchange)
222-
return exchange
240+
return waitForPromiseWithSignal(exchange, signal)
223241
}
224242

225243
export async function sailpointFetch(
@@ -244,6 +262,7 @@ export async function sailpointFetch(
244262
...init,
245263
cache: 'no-store',
246264
headers,
265+
redirect: 'error',
247266
signal: options.signal,
248267
})
249268

@@ -257,7 +276,8 @@ export async function sailpointFetch(
257276
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
258277
await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES)
259278
attempt += 1
260-
await sleep(backoffWithJitter(attempt, retryAfterMs))
279+
await interruptibleSleep(backoffWithJitter(attempt, retryAfterMs), options.signal)
280+
options.signal?.throwIfAborted()
261281
continue
262282
}
263283

@@ -279,7 +299,7 @@ export function readTotalCount(headers: Headers): number | null {
279299
const raw = headers.get('x-total-count')
280300
if (!raw) return null
281301
const parsed = Number(raw)
282-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
302+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null
283303
}
284304

285305
/** Clears process-local authentication state for deterministic tests. */

0 commit comments

Comments
 (0)