Skip to content

Commit 4e148f2

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/stream-recovery-seq-and-ttl-floor
2 parents 5292795 + 51f793b commit 4e148f2

9 files changed

Lines changed: 236 additions & 4 deletions

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ on:
1717
- 'apps/sim/app/desktop/connect/**'
1818
- 'apps/sim/app/credential-groups/**'
1919
- 'apps/sim/hooks/queries/slack-search.ts'
20+
- 'apps/sim/hooks/queries/personal-search-integrations.ts'
21+
- 'apps/sim/hooks/use-search-integration-connection.ts'
2022
- 'apps/sim/hooks/use-github-installation-setup.ts'
2123
- 'apps/sim/app/o/**/integrations/indexed/use-member-enrollment.ts'
2224
- 'apps/sim/lib/api/contracts/desktop-source-connect.ts'

‎apps/desktop/e2e/source-connect.spec.ts‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ test('source authorization returns to its desktop screen and refreshes live', as
5454
const githubInventorySessions: string[] = []
5555
let nativeCredentialVisible = false
5656
let installed = false
57+
let holdSlackStart = false
58+
let canceledSlackRequests = 0
59+
const personalAttempts = new Map<string, { session: string; completed: boolean }>()
60+
let personalInventoryFailed = false
61+
let personalInventoryFailures = 0
5762
let javascript = ''
5863
let stylesheet = ''
5964
let origin = ''
@@ -174,9 +179,83 @@ test('source authorization returns to its desktop screen and refreshes live', as
174179
const state = generateShortId(32)
175180
attempts.set(state, session)
176181
startSessions.push(session)
182+
if (holdSlackStart) {
183+
response.on('close', () => {
184+
if (!response.writableEnded) canceledSlackRequests++
185+
})
186+
return
187+
}
177188
json({ authorizationUrl: `${origin}/provider?state=${state}` })
178189
return
179190
}
191+
if (path === '/api/knowledge/sim-search/personal-integrations') {
192+
if (request.method === 'POST') {
193+
const { oauthCompletionId } = await body()
194+
personalAttempts.set(oauthCompletionId, { session, completed: false })
195+
json({
196+
success: true,
197+
data: { url: `${origin}/personal-provider?completionId=${oauthCompletionId}` },
198+
})
199+
} else if (personalInventoryFailed) {
200+
personalInventoryFailures++
201+
json({ error: 'Inventory temporarily unavailable' }, 503)
202+
} else {
203+
const attempt = personalAttempts.get(url.searchParams.get('completionId') ?? '')
204+
const connected = attempt?.completed === true
205+
json({
206+
success: true,
207+
data: {
208+
completedCredentialId: connected ? 'fixture-personal-account' : null,
209+
connections: connected
210+
? [
211+
{
212+
name: 'Slack',
213+
providerId: 'slack',
214+
connectorType: 'slack',
215+
description: '',
216+
accounts: [
217+
{
218+
credentialId: 'fixture-personal-account',
219+
displayName: 'Fixture',
220+
status: 'connected',
221+
action: null,
222+
},
223+
],
224+
connectionStatus: 'connected',
225+
action: null,
226+
},
227+
]
228+
: [],
229+
available: [
230+
{
231+
name: 'Slack',
232+
description: '',
233+
target: {
234+
type: 'link',
235+
provider: 'slack',
236+
connectorType: 'slack',
237+
connectionMode: 'live',
238+
optionId: 'fixture-option',
239+
},
240+
},
241+
],
242+
nextCursor: null,
243+
},
244+
})
245+
}
246+
return
247+
}
248+
if (path === '/personal-callback') {
249+
const completionId = url.searchParams.get('completionId') ?? ''
250+
const attempt = personalAttempts.get(completionId)
251+
if (!attempt || attempt.session !== session) {
252+
json({ error: 'Wrong attempt' }, 403)
253+
return
254+
}
255+
attempt.completed = true
256+
redirect(`/credential-groups/complete?completionId=${completionId}`)
257+
return
258+
}
180259
if (path === '/api/knowledge/slack/oauth/callback') {
181260
const state = url.searchParams.get('state') ?? ''
182261
callbackSessions.push(session)
@@ -298,6 +377,12 @@ test('source authorization returns to its desktop screen and refreshes live', as
298377
)
299378
return
300379
}
380+
if (path === '/personal-provider') {
381+
response.end(
382+
`<!doctype html><a href="/personal-callback?completionId=${url.searchParams.get('completionId')}">Authorize personal Search</a>`
383+
)
384+
return
385+
}
301386
if (path === '/github-provider') {
302387
response.end(
303388
`<!doctype html><a href="/github-callback?setupId=${url.searchParams.get('setupId')}">Authorize GitHub</a>`
@@ -570,6 +655,68 @@ test('source authorization returns to its desktop screen and refreshes live', as
570655
await expect(web).toHaveURL(`${origin}/o/fixture-organization/integrations`)
571656
await expect(web.getByLabel('Account count')).toHaveText('1')
572657
})
658+
await check('canceling Slack setup aborts the pending web HTTP request', async () => {
659+
holdSlackStart = true
660+
const starts = startSessions.length
661+
try {
662+
await web.getByRole('button', { name: 'Connect Slack', exact: true }).click()
663+
await expect.poll(() => startSessions.length).toBe(starts + 1)
664+
await web.getByRole('button', { name: 'Cancel Slack request', exact: true }).click()
665+
await expect.poll(() => canceledSlackRequests).toBe(1)
666+
await expect(web.getByRole('button', { name: 'Connect Slack', exact: true })).toBeEnabled()
667+
} finally {
668+
holdSlackStart = false
669+
}
670+
})
671+
await check(
672+
'desktop Search preserves pending receipts after inventory failure and allows cancellation/retry',
673+
async () => {
674+
const previousOpens = (await opened()).length
675+
await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
676+
await expect.poll(async () => (await opened()).length).toBe(previousOpens + 1)
677+
await external.goto((await opened())[previousOpens])
678+
await external.getByRole('link', { name: 'Authorize personal Search' }).waitFor()
679+
personalInventoryFailed = true
680+
await external.getByRole('link', { name: 'Authorize personal Search' }).click()
681+
await expect(external).toHaveURL(`${origin}/desktop/done?kind=connect`)
682+
await expect.poll(() => personalInventoryFailures).toBeGreaterThan(0)
683+
await expect(
684+
page.getByRole('button', { name: 'Connect personal Search', exact: true })
685+
).toBeEnabled()
686+
const receipt = () =>
687+
page.evaluate(() => {
688+
const entry = Object.entries(localStorage).find(([key]) =>
689+
key.startsWith('sim.search-connection.')
690+
)
691+
if (!entry) return null
692+
const attempt: { completionId: string; status: string; credentialId?: string } =
693+
JSON.parse(entry[1])
694+
return attempt
695+
})
696+
const pendingReceipt = await receipt()
697+
expect(pendingReceipt).toMatchObject({ status: 'pending' })
698+
await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
699+
expect(await receipt()).toEqual(pendingReceipt)
700+
await page.getByRole('button', { name: 'Cancel personal Search', exact: true }).click()
701+
await expect
702+
.poll(receipt)
703+
.toMatchObject({ completionId: pendingReceipt?.completionId, status: 'failed' })
704+
personalInventoryFailed = false
705+
await page.getByRole('button', { name: 'Retry personal inventory', exact: true }).click()
706+
await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
707+
await expect.poll(async () => (await opened()).length).toBe(previousOpens + 2)
708+
const retryReceipt = await receipt()
709+
expect(retryReceipt).toMatchObject({ status: 'pending' })
710+
expect(retryReceipt?.completionId).not.toBe(pendingReceipt?.completionId)
711+
await external.goto((await opened())[previousOpens + 1])
712+
await external.getByRole('link', { name: 'Authorize personal Search' }).click()
713+
await expect.poll(receipt).toMatchObject({
714+
completionId: retryReceipt?.completionId,
715+
status: 'connected',
716+
credentialId: 'fixture-personal-account',
717+
})
718+
}
719+
)
573720
await page.screenshot({ path: test.info().outputPath('source-connect-desktop.png') })
574721
} finally {
575722
mkdirSync(dirname(reportPath), { recursive: true })
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { authMockFns } from '@sim/testing/mocks/auth.mock'
2+
import { createMockRequest } from '@sim/testing/mocks/request.mock'
3+
import { expect, it } from 'vitest'
4+
import { GET } from '@/app/api/credential-groups/slack-managed-users/callback/route'
5+
6+
it('preserves sign-in recovery when the managed Slack callback loses its session', async () => {
7+
authMockFns.mockGetSession.mockResolvedValueOnce(null)
8+
const response = await GET(
9+
createMockRequest({
10+
url: 'http://localhost/api/credential-groups/slack-managed-users/callback?state=fixture-state&code=fixture-code',
11+
})
12+
)
13+
expect(response.status).toBe(303)
14+
const location = new URL(response.headers.get('location')!)
15+
expect(location.pathname).toBe('/credential-groups/slack-complete')
16+
expect(location.searchParams.get('state')).toBe('fixture-state')
17+
expect(location.searchParams.get('reason')).toBe('signin_required')
18+
})

‎apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4141
ok: false,
4242
message: 'Sign in to Sim to complete this Slack setup.',
4343
state: rawState,
44-
reason: 'unauthenticated',
44+
reason: 'signin_required',
4545
})
4646
}
4747
const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { authMockFns } from '@sim/testing/mocks/auth.mock'
2+
import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock'
3+
import { createMockRequest } from '@sim/testing/mocks/request.mock'
4+
import { expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock)
7+
8+
import { POST } from '@/app/api/desktop/source-connect/route'
9+
10+
it.each([true, false])(
11+
'rejects an oversized desktop request before JSON decoding (declared length: %s)',
12+
async (declaredLength) => {
13+
authMockFns.mockGetSession.mockResolvedValueOnce({
14+
user: { id: 'fixture-user' },
15+
session: { id: 'fixture-session' },
16+
})
17+
rateLimiterMockFns.mockEnforceUserRateLimit.mockResolvedValueOnce(null)
18+
const rawBody = ' '.repeat(64 * 1024 + 1)
19+
const response = await POST(
20+
createMockRequest({
21+
method: 'POST',
22+
url: 'http://localhost/api/desktop/source-connect',
23+
rawBody,
24+
headers: {
25+
'content-type': 'application/json',
26+
...(declaredLength ? { 'content-length': String(rawBody.length) } : {}),
27+
},
28+
})
29+
)
30+
expect(response.status).toBe(413)
31+
}
32+
)

‎apps/sim/app/api/desktop/source-connect/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const POST = defineInternalJsonRoute({
1313
operation: createDesktopSourceRequest.operation,
1414
rateLimit: internalRateLimits.user({ bucketName: 'desktop-source-connect' }),
1515
errorPolicy: internalOrchestrationErrorPolicy,
16+
parseOptions: { maxBodyBytes: 64 * 1024 },
1617
mapInput: ({ body }) => ({ requestId: body.requestId, payload: JSON.stringify(body.request) }),
1718
useCase: createDesktopSourceRequest,
1819
staticResponseHeaders: { 'Cache-Control': 'no-store' },

‎apps/sim/hooks/queries/slack-search.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function useStartSlackSearchOAuth() {
4848
await connectDesktopSource({ kind: 'slack-search', body }, signal)
4949
return null
5050
}
51-
return requestJson(startSlackSearchOAuthContract, { body })
51+
return requestJson(startSlackSearchOAuthContract, { body, signal })
5252
},
5353
onSettled: (_data, _error, input) =>
5454
Promise.all([

‎apps/sim/hooks/use-search-integration-connection.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export function useSearchIntegrationConnection({
160160
return
161161
}
162162
const desktop = isDesktopApp()
163+
if (desktop && pending) return
163164
const tab = desktop ? null : window.open('about:blank', '_blank', 'width=600,height=700')
164165
if (!desktop && !tab) {
165166
setLocalError('Allow pop-ups for this site to connect your account.')

‎apps/sim/scripts/fixtures/desktop-source-connect.tsx‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/hooks/queries/organization-accounts'
1616
import { useSlackSearchInstallations, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search'
1717
import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup'
18+
import { useSearchIntegrationConnection } from '@/hooks/use-search-integration-connection'
1819

1920
const NO_CONNECTIONS = new Set<string>()
2021
const MEMBERSHIP_KEYS: readonly (readonly string[])[] = []
@@ -32,7 +33,20 @@ function SourceConnectFixture() {
3233
organizationId: 'fixture-organization',
3334
onConnected: setGithubCredential,
3435
})
36+
const slackAbort = useRef<AbortController | null>(null)
3537
const connection = useStartSlackSearchOAuth()
38+
const personal = useSearchIntegrationConnection({
39+
organizationId: 'fixture-organization',
40+
userId: 'fixture-user',
41+
controlId: 'fixture-search-card',
42+
target: {
43+
type: 'link',
44+
provider: 'slack',
45+
connectorType: 'slack',
46+
connectionMode: 'live',
47+
optionId: 'fixture-option',
48+
},
49+
})
3650
const inventory = useSlackSearchInstallations('fixture-organization')
3751
return (
3852
<main className='flex flex-col items-start gap-2 p-6'>
@@ -72,17 +86,34 @@ function SourceConnectFixture() {
7286
<input aria-label='Source draft' defaultValue='Unsubmitted source name' />
7387
<button
7488
disabled={connection.isPending}
75-
onClick={() =>
89+
onClick={() => {
90+
const controller = new AbortController()
91+
slackAbort.current = controller
7692
connection.mutate({
93+
signal: controller.signal,
7794
organizationId: 'fixture-organization',
7895
name: 'Search',
7996
description: 'Search fixture',
8097
mode: 'shared',
8198
})
82-
}
99+
}}
83100
>
84101
Connect Slack
85102
</button>
103+
<button onClick={() => slackAbort.current?.abort()}>Cancel Slack request</button>
104+
<button
105+
disabled={
106+
personal.isLoading ||
107+
personal.isStarting ||
108+
personal.connected ||
109+
(!personal.available && !personal.pending)
110+
}
111+
onClick={() => void personal.connect()}
112+
>
113+
Connect personal Search
114+
</button>
115+
<button onClick={personal.cancel}>Cancel personal Search</button>
116+
<button onClick={() => void personal.retry()}>Retry personal inventory</button>
86117
<button disabled={github.pending} onClick={() => void github.connect()}>
87118
Connect GitHub
88119
</button>

0 commit comments

Comments
 (0)