Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5893,6 +5893,18 @@ export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function SailPointIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<path
fill='currentColor'
d='M7.25 2.4c5.66 1.7 9.2 6.86 9.9 15.35a.75.75 0 0 1-.75.81H7.25a.75.75 0 0 1-.75-.75V3.12a.75.75 0 0 1 .95-.72Z'
/>
<rect fill='currentColor' x='3' y='19.9' width='18' height='1.8' rx='.9' />
</svg>
)
}

export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 273 191'>
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ import {
RootlyIcon,
RssIcon,
S3Icon,
SailPointIcon,
SalesforceIcon,
SapConcurIcon,
SapS4HanaIcon,
Expand Down Expand Up @@ -510,6 +511,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
rootly: RootlyIcon,
rss: RssIcon,
s3: S3Icon,
sailpoint: SailPointIcon,
salesforce: SalesforceIcon,
sap_concur: SapConcurIcon,
sap_s4hana: SapS4HanaIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@
"rocketlane",
"rootly",
"s3",
"sailpoint",
"salesforce",
"salesforce-service-account",
"sap_concur",
Expand Down
897 changes: 897 additions & 0 deletions apps/docs/content/docs/integrations/sailpoint.mdx

Large diffs are not rendered by default.

1,826 changes: 1,826 additions & 0 deletions apps/sim/blocks/blocks/sailpoint.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/sim/blocks/registry-maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ import { RootlyBlock, RootlyBlockMeta } from '@/blocks/blocks/rootly'
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
import { RssBlock, RssBlockMeta } from '@/blocks/blocks/rss'
import { S3Block, S3BlockMeta } from '@/blocks/blocks/s3'
import { SailPointBlock, SailPointBlockMeta } from '@/blocks/blocks/sailpoint'
import { SalesforceBlock, SalesforceBlockMeta } from '@/blocks/blocks/salesforce'
import { SapConcurBlock, SapConcurBlockMeta } from '@/blocks/blocks/sap_concur'
import { SapS4HanaBlock, SapS4HanaBlockMeta } from '@/blocks/blocks/sap_s4hana'
Expand Down Expand Up @@ -629,6 +630,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
router_v2: RouterV2Block,
rss: RssBlock,
s3: S3Block,
sailpoint: SailPointBlock,
salesforce: SalesforceBlock,
sap_concur: SapConcurBlock,
sap_s4hana: SapS4HanaBlock,
Expand Down Expand Up @@ -943,6 +945,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
rootly: RootlyBlockMeta,
rss: RssBlockMeta,
s3: S3BlockMeta,
sailpoint: SailPointBlockMeta,
salesforce: SalesforceBlockMeta,
sap_concur: SapConcurBlockMeta,
sap_s4hana: SapS4HanaBlockMeta,
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5893,6 +5893,18 @@ export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function SailPointIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<path
fill='currentColor'
d='M7.25 2.4c5.66 1.7 9.2 6.86 9.9 15.35a.75.75 0 0 1-.75.81H7.25a.75.75 0 0 1-.75-.75V3.12a.75.75 0 0 1 .95-.72Z'
/>
<rect fill='currentColor' x='3' y='19.9' width='18' height='1.8' rx='.9' />
</svg>
)
}

export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 273 191'>
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/copilot/generated/docs-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export const DOCS_MANIFEST: readonly string[] = [
'integrations/rocketlane.mdx',
'integrations/rootly.mdx',
'integrations/s3.mdx',
'integrations/sailpoint.mdx',
'integrations/salesforce-service-account.mdx',
'integrations/salesforce.mdx',
'integrations/sap_concur.mdx',
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/integrations/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ import {
RootlyIcon,
RssIcon,
S3Icon,
SailPointIcon,
SalesforceIcon,
SapConcurIcon,
SapS4HanaIcon,
Expand Down Expand Up @@ -491,6 +492,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
rootly: RootlyIcon,
rss: RssIcon,
s3: S3Icon,
sailpoint: SailPointIcon,
salesforce: SalesforceIcon,
sap_concur: SapConcurIcon,
sap_s4hana: SapS4HanaIcon,
Expand Down
109 changes: 109 additions & 0 deletions apps/sim/lib/internal/sailpoint/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearSailPointTokenStateForTests,
getSailPointAccessToken,
getSailPointTokenStateForTests,
resolveSailPointHosts,
sailpointFetch,
} from '@/lib/internal/sailpoint/client'

const mockFetch = vi.fn<typeof fetch>()

function tokenResponse(token: string, expiresIn = 3600): Response {
return Response.json({ access_token: token, expires_in: expiresIn })
}

describe('SailPoint client', () => {
beforeEach(() => {
clearSailPointTokenStateForTests()
mockFetch.mockReset()
vi.stubGlobal('fetch', mockFetch)
})

afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})

it('accepts only commercial and government tenant hosts', () => {
expect(resolveSailPointHosts('acme').host).toBe('acme.api.identitynow.com')
expect(resolveSailPointHosts('https://agency.api.identitynowgov.com').host).toBe(
'agency.api.identitynowgov.com'
)
expect(() => resolveSailPointHosts('acme.api.identitynow.com.evil.test')).toThrow(
'not an allowed'
)
})

it('isolates cache entries by the exact credential secret', async () => {
mockFetch
.mockResolvedValueOnce(tokenResponse('first'))
.mockResolvedValueOnce(tokenResponse('second'))

const common = { tenant: 'acme', clientId: 'client' }
expect(await getSailPointAccessToken({ ...common, clientSecret: 'one' })).toBe('first')
expect(await getSailPointAccessToken({ ...common, clientSecret: 'two' })).toBe('second')
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('single-flights concurrent exchanges for the same credentials', async () => {
let release: ((response: Response) => void) | undefined
mockFetch.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
release = resolve
})
)
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
const first = getSailPointAccessToken(credentials)
const second = getSailPointAccessToken(credentials)
expect(mockFetch).toHaveBeenCalledTimes(1)
release?.(tokenResponse('shared'))
await expect(Promise.all([first, second])).resolves.toEqual(['shared', 'shared'])
})

it('expires cached tokens before their provider expiry', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
mockFetch
.mockResolvedValueOnce(tokenResponse('old', 100))
.mockResolvedValueOnce(tokenResponse('new', 100))
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }

expect(await getSailPointAccessToken(credentials)).toBe('old')
vi.setSystemTime(new Date('2026-01-01T00:01:31.000Z'))
expect(await getSailPointAccessToken(credentials)).toBe('new')
})

it('evicts the oldest token when the bounded cache is full', async () => {
mockFetch.mockImplementation(async () => tokenResponse('token'))
for (let index = 0; index < 101; index += 1) {
await getSailPointAccessToken({
tenant: 'acme',
clientId: `client-${index}`,
clientSecret: 'secret',
})
}
expect(getSailPointTokenStateForTests()).toEqual({ cacheSize: 100, exchangeSize: 0 })
})

it('rejects provider responses larger than the shared JSON cap', async () => {
mockFetch.mockResolvedValueOnce(tokenResponse('token')).mockResolvedValueOnce(
new Response('{}', {
status: 200,
headers: { 'content-length': String(10 * 1024 * 1024 + 1) },
})
)
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }

await expect(
sailpointFetch(credentials, (hosts) => ({
url: `${hosts.apiBaseUrl}/identities/v1`,
init: { method: 'GET' },
}))
).rejects.toThrow(/maximum|limit|exceeds/i)
})
})
Loading
Loading