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
2 changes: 2 additions & 0 deletions .github/workflows/desktop-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ on:
- 'apps/sim/app/desktop/connect/**'
- 'apps/sim/app/credential-groups/**'
- 'apps/sim/hooks/queries/slack-search.ts'
- 'apps/sim/hooks/queries/personal-search-integrations.ts'
- 'apps/sim/hooks/use-search-integration-connection.ts'
- 'apps/sim/hooks/use-github-installation-setup.ts'
- 'apps/sim/app/o/**/integrations/indexed/use-member-enrollment.ts'
- 'apps/sim/lib/api/contracts/desktop-source-connect.ts'
Expand Down
147 changes: 147 additions & 0 deletions apps/desktop/e2e/source-connect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ test('source authorization returns to its desktop screen and refreshes live', as
const githubInventorySessions: string[] = []
let nativeCredentialVisible = false
let installed = false
let holdSlackStart = false
let canceledSlackRequests = 0
const personalAttempts = new Map<string, { session: string; completed: boolean }>()
let personalInventoryFailed = false
let personalInventoryFailures = 0
let javascript = ''
let stylesheet = ''
let origin = ''
Expand Down Expand Up @@ -174,9 +179,83 @@ test('source authorization returns to its desktop screen and refreshes live', as
const state = generateShortId(32)
attempts.set(state, session)
startSessions.push(session)
if (holdSlackStart) {
response.on('close', () => {
if (!response.writableEnded) canceledSlackRequests++
})
return
}
json({ authorizationUrl: `${origin}/provider?state=${state}` })
return
}
if (path === '/api/knowledge/sim-search/personal-integrations') {
if (request.method === 'POST') {
const { oauthCompletionId } = await body()
personalAttempts.set(oauthCompletionId, { session, completed: false })
json({
success: true,
data: { url: `${origin}/personal-provider?completionId=${oauthCompletionId}` },
})
} else if (personalInventoryFailed) {
personalInventoryFailures++
json({ error: 'Inventory temporarily unavailable' }, 503)
} else {
const attempt = personalAttempts.get(url.searchParams.get('completionId') ?? '')
const connected = attempt?.completed === true
json({
success: true,
data: {
completedCredentialId: connected ? 'fixture-personal-account' : null,
connections: connected
? [
{
name: 'Slack',
providerId: 'slack',
connectorType: 'slack',
description: '',
accounts: [
{
credentialId: 'fixture-personal-account',
displayName: 'Fixture',
status: 'connected',
action: null,
},
],
connectionStatus: 'connected',
action: null,
},
]
: [],
available: [
{
name: 'Slack',
description: '',
target: {
type: 'link',
provider: 'slack',
connectorType: 'slack',
connectionMode: 'live',
optionId: 'fixture-option',
},
},
],
nextCursor: null,
},
})
}
return
}
if (path === '/personal-callback') {
const completionId = url.searchParams.get('completionId') ?? ''
const attempt = personalAttempts.get(completionId)
if (!attempt || attempt.session !== session) {
json({ error: 'Wrong attempt' }, 403)
return
}
attempt.completed = true
redirect(`/credential-groups/complete?completionId=${completionId}`)
return
}
if (path === '/api/knowledge/slack/oauth/callback') {
const state = url.searchParams.get('state') ?? ''
callbackSessions.push(session)
Expand Down Expand Up @@ -298,6 +377,12 @@ test('source authorization returns to its desktop screen and refreshes live', as
)
return
}
if (path === '/personal-provider') {
response.end(
`<!doctype html><a href="/personal-callback?completionId=${url.searchParams.get('completionId')}">Authorize personal Search</a>`
)
return
}
if (path === '/github-provider') {
response.end(
`<!doctype html><a href="/github-callback?setupId=${url.searchParams.get('setupId')}">Authorize GitHub</a>`
Expand Down Expand Up @@ -570,6 +655,68 @@ test('source authorization returns to its desktop screen and refreshes live', as
await expect(web).toHaveURL(`${origin}/o/fixture-organization/integrations`)
await expect(web.getByLabel('Account count')).toHaveText('1')
})
await check('canceling Slack setup aborts the pending web HTTP request', async () => {
holdSlackStart = true
const starts = startSessions.length
try {
await web.getByRole('button', { name: 'Connect Slack', exact: true }).click()
await expect.poll(() => startSessions.length).toBe(starts + 1)
await web.getByRole('button', { name: 'Cancel Slack request', exact: true }).click()
await expect.poll(() => canceledSlackRequests).toBe(1)
await expect(web.getByRole('button', { name: 'Connect Slack', exact: true })).toBeEnabled()
} finally {
holdSlackStart = false
}
})
await check(
'desktop Search preserves pending receipts after inventory failure and allows cancellation/retry',
async () => {
const previousOpens = (await opened()).length
await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
await expect.poll(async () => (await opened()).length).toBe(previousOpens + 1)
await external.goto((await opened())[previousOpens])
await external.getByRole('link', { name: 'Authorize personal Search' }).waitFor()
personalInventoryFailed = true
await external.getByRole('link', { name: 'Authorize personal Search' }).click()
await expect(external).toHaveURL(`${origin}/desktop/done?kind=connect`)
await expect.poll(() => personalInventoryFailures).toBeGreaterThan(0)
await expect(
page.getByRole('button', { name: 'Connect personal Search', exact: true })
).toBeEnabled()
const receipt = () =>
page.evaluate(() => {
const entry = Object.entries(localStorage).find(([key]) =>
key.startsWith('sim.search-connection.')
)
if (!entry) return null
const attempt: { completionId: string; status: string; credentialId?: string } =
JSON.parse(entry[1])
return attempt
})
const pendingReceipt = await receipt()
expect(pendingReceipt).toMatchObject({ status: 'pending' })
await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
expect(await receipt()).toEqual(pendingReceipt)
await page.getByRole('button', { name: 'Cancel personal Search', exact: true }).click()
await expect
.poll(receipt)
.toMatchObject({ completionId: pendingReceipt?.completionId, status: 'failed' })
personalInventoryFailed = false
await page.getByRole('button', { name: 'Retry personal inventory', exact: true }).click()
await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
await expect.poll(async () => (await opened()).length).toBe(previousOpens + 2)
const retryReceipt = await receipt()
expect(retryReceipt).toMatchObject({ status: 'pending' })
expect(retryReceipt?.completionId).not.toBe(pendingReceipt?.completionId)
await external.goto((await opened())[previousOpens + 1])
await external.getByRole('link', { name: 'Authorize personal Search' }).click()
await expect.poll(receipt).toMatchObject({
completionId: retryReceipt?.completionId,
status: 'connected',
credentialId: 'fixture-personal-account',
})
}
)
await page.screenshot({ path: test.info().outputPath('source-connect-desktop.png') })
} finally {
mkdirSync(dirname(reportPath), { recursive: true })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { authMockFns } from '@sim/testing/mocks/auth.mock'
import { createMockRequest } from '@sim/testing/mocks/request.mock'
import { expect, it } from 'vitest'
import { GET } from '@/app/api/credential-groups/slack-managed-users/callback/route'

it('preserves sign-in recovery when the managed Slack callback loses its session', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const response = await GET(
createMockRequest({
url: 'http://localhost/api/credential-groups/slack-managed-users/callback?state=fixture-state&code=fixture-code',
})
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.pathname).toBe('/credential-groups/slack-complete')
expect(location.searchParams.get('state')).toBe('fixture-state')
expect(location.searchParams.get('reason')).toBe('signin_required')
})
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
ok: false,
message: 'Sign in to Sim to complete this Slack setup.',
state: rawState,
reason: 'unauthenticated',
reason: 'signin_required',
})
}
const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {})
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/app/api/desktop/source-connect/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { authMockFns } from '@sim/testing/mocks/auth.mock'
import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock'
import { createMockRequest } from '@sim/testing/mocks/request.mock'
import { expect, it, vi } from 'vitest'

vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock)

import { POST } from '@/app/api/desktop/source-connect/route'

it.each([true, false])(
'rejects an oversized desktop request before JSON decoding (declared length: %s)',
async (declaredLength) => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'fixture-user' },
session: { id: 'fixture-session' },
})
rateLimiterMockFns.mockEnforceUserRateLimit.mockResolvedValueOnce(null)
const rawBody = ' '.repeat(64 * 1024 + 1)
const response = await POST(
createMockRequest({
method: 'POST',
url: 'http://localhost/api/desktop/source-connect',
rawBody,
headers: {
'content-type': 'application/json',
...(declaredLength ? { 'content-length': String(rawBody.length) } : {}),
},
})
)
expect(response.status).toBe(413)
}
)
1 change: 1 addition & 0 deletions apps/sim/app/api/desktop/source-connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const POST = defineInternalJsonRoute({
operation: createDesktopSourceRequest.operation,
rateLimit: internalRateLimits.user({ bucketName: 'desktop-source-connect' }),
errorPolicy: internalOrchestrationErrorPolicy,
parseOptions: { maxBodyBytes: 64 * 1024 },
mapInput: ({ body }) => ({ requestId: body.requestId, payload: JSON.stringify(body.request) }),
useCase: createDesktopSourceRequest,
staticResponseHeaders: { 'Cache-Control': 'no-store' },
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/slack-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function useStartSlackSearchOAuth() {
await connectDesktopSource({ kind: 'slack-search', body }, signal)
return null
}
return requestJson(startSlackSearchOAuthContract, { body })
return requestJson(startSlackSearchOAuthContract, { body, signal })
},
onSettled: (_data, _error, input) =>
Promise.all([
Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/use-search-integration-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export function useSearchIntegrationConnection({
return
}
const desktop = isDesktopApp()
if (desktop && pending) return
const tab = desktop ? null : window.open('about:blank', '_blank', 'width=600,height=700')
if (!desktop && !tab) {
setLocalError('Allow pop-ups for this site to connect your account.')
Expand Down
35 changes: 33 additions & 2 deletions apps/sim/scripts/fixtures/desktop-source-connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@/hooks/queries/organization-accounts'
import { useSlackSearchInstallations, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search'
import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup'
import { useSearchIntegrationConnection } from '@/hooks/use-search-integration-connection'

const NO_CONNECTIONS = new Set<string>()
const MEMBERSHIP_KEYS: readonly (readonly string[])[] = []
Expand All @@ -32,7 +33,20 @@ function SourceConnectFixture() {
organizationId: 'fixture-organization',
onConnected: setGithubCredential,
})
const slackAbort = useRef<AbortController | null>(null)
const connection = useStartSlackSearchOAuth()
const personal = useSearchIntegrationConnection({
organizationId: 'fixture-organization',
userId: 'fixture-user',
controlId: 'fixture-search-card',
target: {
type: 'link',
provider: 'slack',
connectorType: 'slack',
connectionMode: 'live',
optionId: 'fixture-option',
},
})
const inventory = useSlackSearchInstallations('fixture-organization')
return (
<main className='flex flex-col items-start gap-2 p-6'>
Expand Down Expand Up @@ -72,17 +86,34 @@ function SourceConnectFixture() {
<input aria-label='Source draft' defaultValue='Unsubmitted source name' />
<button
disabled={connection.isPending}
onClick={() =>
onClick={() => {
const controller = new AbortController()
slackAbort.current = controller
connection.mutate({
signal: controller.signal,
organizationId: 'fixture-organization',
name: 'Search',
description: 'Search fixture',
mode: 'shared',
})
}
}}
>
Connect Slack
</button>
<button onClick={() => slackAbort.current?.abort()}>Cancel Slack request</button>
<button
disabled={
personal.isLoading ||
personal.isStarting ||
personal.connected ||
(!personal.available && !personal.pending)
}
onClick={() => void personal.connect()}
>
Connect personal Search
</button>
<button onClick={personal.cancel}>Cancel personal Search</button>
<button onClick={() => void personal.retry()}>Retry personal inventory</button>
<button disabled={github.pending} onClick={() => void github.connect()}>
Connect GitHub
</button>
Expand Down
Loading