Skip to content

Commit c852a69

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/dashboard-mention-resolve
# Conflicts: # apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts # apps/sim/components/charts/time-series-chart.tsx # apps/sim/lib/dashboards/repository.integration.ts # apps/sim/lib/mothership/chat/display-message.ts
2 parents e3af83d + e1e8689 commit c852a69

30 files changed

Lines changed: 618 additions & 68 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 })

‎apps/sim/.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
269269

270270
# Zoom member search: separate General OAuth app to preserve workflow grants.
271271
# Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback with the two meeting read scopes.
272+
# ZOOM_SEARCH=false # Off-AppConfig fallback; set true to enable for eligible organization-owned scopes
272273
# ZOOM_MCP_CLIENT_ID=
273274
# ZOOM_MCP_CLIENT_SECRET=

‎apps/sim/app/api/billing/update-cost/route.test.ts‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,32 +1040,38 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10401040
})
10411041
})
10421042

1043-
it('never pauses a blocked payer with the usage card', async () => {
1044-
billingAttributionMockFns.mockCheckAccountBillingBlocks.mockResolvedValue({
1045-
blocked: true,
1046-
scope: 'payer',
1043+
it("offers the card for its admitted payer's plan, not the actor's current one", async () => {
1044+
billingCoreMockFns.mockGetOrganizationSubscription.mockResolvedValue({
1045+
id: 'sub-account-org',
1046+
referenceId: 'account-org',
1047+
plan: 'team',
1048+
status: 'active',
1049+
seats: 4,
1050+
})
1051+
billingPlanMockFns.mockGetHighestPrioritySubscription.mockResolvedValue({
1052+
id: 'sub-personal',
1053+
referenceId: 'user-1',
1054+
plan: 'pro',
1055+
status: 'active',
10471056
})
10481057

10491058
const body = await (await POST(directCallback())).json()
10501059

1051-
expect(body.usageExceeded).toBe(false)
1060+
expect(body.usageUpgrade).toMatchObject({
1061+
action: 'increase_limit',
1062+
message: expect.stringContaining("organization's usage limit"),
1063+
})
10521064
})
10531065

1054-
it('keeps the exceeded verdict with the plan-upgrade card when the card read outlasts the callback budget', async () => {
1055-
billingPlanMockFns.mockGetHighestPrioritySubscription.mockImplementation(async () => {
1056-
await sleep(1500)
1057-
return { plan: 'pro' }
1066+
it('never pauses a blocked payer with the usage card', async () => {
1067+
billingAttributionMockFns.mockCheckAccountBillingBlocks.mockResolvedValue({
1068+
blocked: true,
1069+
scope: 'payer',
10581070
})
1059-
const startedAt = Date.now()
10601071

10611072
const body = await (await POST(directCallback())).json()
10621073

1063-
expect(body).toMatchObject({
1064-
success: true,
1065-
usageExceeded: true,
1066-
usageUpgrade: { action: 'upgrade_plan' },
1067-
})
1068-
expect(Date.now() - startedAt).toBeLessThan(1400)
1074+
expect(body.usageExceeded).toBe(false)
10691075
})
10701076
})
10711077

‎apps/sim/app/api/billing/update-cost/route.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp
8686
* already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the
8787
* refusal to the next step or re-check rather than ending a paying run on a database blip,
8888
* and so does a verdict read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. An exceeded
89-
* verdict always pauses the run; a card read past that budget falls back to the plan-upgrade card.
89+
* verdict always pauses the run.
9090
*/
9191
async function readUsageStanding(
9292
userId: string,
@@ -99,10 +99,9 @@ async function readUsageStanding(
9999
? () => readMidRunAccountUsageVerdict(accountDecision)
100100
: null
101101
if (!isHosted || !readVerdict) return { usageExceeded: false }
102-
const deadlineAt = Date.now() + USAGE_STANDING_TIMEOUT_MS
103102
let verdict: MidRunUsageVerdict
104103
try {
105-
verdict = await withinDeadline(readVerdict, deadlineAt)
104+
verdict = await withinDeadline(readVerdict, Date.now() + USAGE_STANDING_TIMEOUT_MS)
106105
} catch {
107106
logger.warn('Usage standing read outlasted the callback budget; answering not exceeded')
108107
return { usageExceeded: false }
@@ -114,9 +113,8 @@ async function readUsageStanding(
114113
usageExceeded: true,
115114
usageUpgrade: await resolveUsageUpgradePayload(
116115
userId,
117-
billingAttribution,
118-
verdict.scope,
119-
deadlineAt
116+
billingAttribution ?? verdict.payer,
117+
verdict.scope
120118
),
121119
}
122120
}

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
140140

141141
vi.mock('@/lib/workspaces/utils', () => workspacesUtilsMock)
142142

143-
import { validateCopilotApiKeyBodySchema } from '@/lib/api/contracts/copilot'
143+
import {
144+
validateCopilotApiKeyBodySchema,
145+
validateCopilotApiKeyContract,
146+
} from '@/lib/api/contracts/copilot'
144147
import { resetMidRunUsageCaches } from '@/lib/billing/core/mid-run-usage'
145148
import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache'
146149
import { POST } from '@/app/api/copilot/api-keys/validate/route'
@@ -248,6 +251,17 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
248251
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
249252
})
250253

254+
it("sends a new turn's usage refusal as the empty 402 its contract declares", async () => {
255+
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
256+
257+
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))
258+
259+
expect(res.status).toBe(402)
260+
expect(await res.text()).toBe('')
261+
const refusalSchema = validateCopilotApiKeyContract.response.statusSchemas?.[402]
262+
expect(refusalSchema?.safeParse(undefined).success).toBe(true)
263+
})
264+
251265
it('preserves the actor member cap for markerless self-hosted admission', async () => {
252266
mockCheckAttributedUsageLimits.mockResolvedValue({
253267
isExceeded: true,
@@ -593,6 +607,28 @@ describe('validation lifecycle purposes', () => {
593607
})
594608
})
595609

610+
it("refuses a direct-v1 continuation with the card for its admitted payer's plan", async () => {
611+
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
612+
mockGetOrganizationSubscription.mockResolvedValue({
613+
id: 'sub-account-org',
614+
referenceId: 'account-org',
615+
plan: 'team',
616+
status: 'active',
617+
seats: 4,
618+
})
619+
mockGetHighestPrioritySubscription.mockResolvedValue(null)
620+
621+
const response = await POST(request(body, directHeaders))
622+
623+
expect(response.status).toBe(402)
624+
await expect(response.json()).resolves.toMatchObject({
625+
usageUpgrade: {
626+
action: 'increase_limit',
627+
message: expect.stringContaining("organization's usage limit"),
628+
},
629+
})
630+
})
631+
596632
it('admits a direct-v1 continuation whose usage cannot be read', async () => {
597633
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, unavailable: true })
598634
expect((await POST(request(body, directHeaders))).status).toBe(200)

‎apps/sim/app/api/copilot/api-keys/validate/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
453453
span.setAttribute(TraceAttr.HttpStatusCode, 402)
454454
const usageUpgrade = await resolveUsageUpgradePayload(
455455
userId,
456-
billing?.kind === 'attributed' ? billing.attribution : undefined,
456+
billing?.kind === 'attributed' ? billing.attribution : verdict.payer,
457457
verdict.scope
458458
)
459459
return NextResponse.json<ValidateCopilotApiKeyUsageExceeded>(
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, {})

0 commit comments

Comments
 (0)