Skip to content
Open

Dev #5410

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b4e2003
feat(scout): add scout agent
Sg312 Jul 3, 2026
ad305af
fix(contracts): update contracts to include scout agent
Sg312 Jul 3, 2026
5aa8a25
feat(copilot): search agent (research+scout merge) + read-only table/…
Sg312 Jul 3, 2026
981471d
feat(copilot): run_code compute-only handler; docs lint fix
Sg312 Jul 3, 2026
ccd1482
fix(copilot): failed tool calls must surface their error in terminal …
Sg312 Jul 3, 2026
b88b4ae
feat(chat): render inline question tags from the agent in chat
emir-karabeg Jul 4, 2026
518a089
fix(chat): let inert multi-step questions browse all prompts
emir-karabeg Jul 4, 2026
ff74759
improvement(chat): guard question answer formatting against sparse ar…
emir-karabeg Jul 4, 2026
31c31aa
chore(copilot): drop user_memory from generated contracts and tool di…
Sg312 Jul 4, 2026
b39847a
improvement(chat): answered question card becomes the user turn; two …
Sg312 Jul 4, 2026
0e15ba5
improvement(chat): question cards are single_select only
Sg312 Jul 4, 2026
0eb2cb6
improvement(chat): bring back multi_select question cards
Sg312 Jul 4, 2026
c2f1f34
chore(copilot): regenerate mothership contract mirror (chat blob span…
Sg312 Jul 4, 2026
3e39b0d
chore(copilot): regenerate mothership contract mirror (chat blob metr…
Sg312 Jul 6, 2026
1092a59
feat(secrets): make output of generate api key a secret
Sg312 Jul 7, 2026
60c5023
feat(cli): add mkdir, mv, cp to mship tool set
Sg312 Jul 8, 2026
65f14a6
feat(fork-chat): add fork chat to mothership
Sg312 Jul 8, 2026
beeb7f4
fix(fork-chat): fix messageid handling in fork chat
Sg312 Jul 8, 2026
155c4e6
feat(credentials): agent-initiated oauth credential reconnect (#5488)
j15z Jul 8, 2026
587bbd1
fix(conflicts): remove migration
Sg312 Jul 8, 2026
8d04894
fix(conflicts): fix conflicts
Sg312 Jul 8, 2026
713c86d
fix(fork-chat): add migrations back
Sg312 Jul 8, 2026
0d04133
fix(ci): fix lint
Sg312 Jul 8, 2026
a8454bd
fix(ci): fix bad import
Sg312 Jul 8, 2026
82253ee
fix(vfs): fix 500 char limit in vfs for skills and custom tools
Sg312 Jul 9, 2026
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: 0 additions & 2 deletions apps/docs/components/workflow-preview/format-references.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
<span key={index} className='text-[var(--brand-secondary)]'>
{part}
</span>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
<span key={index}>{part}</span>
)
})
Expand Down
323 changes: 323 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
/**
* @vitest-environment node
*/
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockGetSession,
mockOAuth2LinkAccount,
mockCheckWorkspaceAccess,
mockGetCredentialActorContext,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockOAuth2LinkAccount: vi.fn(),
mockCheckWorkspaceAccess: vi.fn(),
mockGetCredentialActorContext: vi.fn(),
}))

vi.mock('@sim/db', () => dbChainMock)

vi.mock('@/lib/auth/auth', () => ({
auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
getSession: mockGetSession,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: mockCheckWorkspaceAccess,
}))

vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: mockGetCredentialActorContext,
}))

vi.mock('@/lib/oauth/utils', () => ({
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
}))

import { GET } from '@/app/api/auth/oauth2/authorize/route'

const BASE_URL = 'https://sim.test'
const WORKSPACE_ID = 'ws-1'
const USER_ID = 'user-1'
const CREDENTIAL_ID = 'cred-1'
const LINK_URL = 'https://provider.example/authorize?state=abc'

function authorizeRequest(query: Record<string, string>) {
const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value)
}
return createMockRequest('GET', undefined, {}, url.toString())
}

function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
return {
credential: {
id: CREDENTIAL_ID,
workspaceId: WORKSPACE_ID,
type: 'oauth',
providerId: 'google-email',
displayName: 'Work Gmail',
...((overrides.credential as Record<string, unknown>) ?? {}),
},
member: null,
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: true,
...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')),
}
}

describe('OAuth2 authorize route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
mockCheckWorkspaceAccess.mockResolvedValue({
hasAccess: true,
canWrite: true,
canAdmin: false,
workspace: { id: WORKSPACE_ID },
})
mockOAuth2LinkAccount.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ url: LINK_URL }),
headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] },
})
})

describe('plain connect (no credentialId)', () => {
it('creates a draft with credentialId null and redirects to the provider', async () => {
const response = await GET(
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
)

expect(response.headers.get('location')).toBe(LINK_URL)
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: USER_ID,
workspaceId: WORKSPACE_ID,
providerId: 'google-email',
credentialId: null,
})
)
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
set: expect.objectContaining({ credentialId: null }),
})
)
})

it('numbers the draft display name when the default collides with an existing credential', async () => {
dbChainMockFns.where
.mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }]))
.mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }]))

await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))

expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({ displayName: "Justin's Gmail 2" })
)
})

it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => {
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))

const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0]
expect(set).toHaveProperty('credentialId', null)
})

it('redirects to login when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)

const response = await GET(
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
)

expect(response.headers.get('location')).toContain('/login')
expect(dbChainMockFns.values).not.toHaveBeenCalled()
})

it('rejects without workspace write access', async () => {
mockCheckWorkspaceAccess.mockResolvedValue({
hasAccess: true,
canWrite: false,
canAdmin: false,
workspace: { id: WORKSPACE_ID },
})

const response = await GET(
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=workspace_access_denied`
)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
})
})

describe('reconnect (credentialId present)', () => {
it('creates a reconnect draft carrying credentialId in values and upsert set', async () => {
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())

const response = await GET(
authorizeRequest({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: CREDENTIAL_ID,
})
)

expect(response.headers.get('location')).toBe(LINK_URL)
expect(mockGetCredentialActorContext).toHaveBeenCalledWith(
CREDENTIAL_ID,
USER_ID,
expect.objectContaining({ workspaceAccess: expect.anything() })
)
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({ credentialId: CREDENTIAL_ID })
)
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
set: expect.objectContaining({ credentialId: CREDENTIAL_ID }),
})
)
})

it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => {
mockGetCredentialActorContext.mockResolvedValue(
oauthCredentialActor({ credential: { displayName: 'Renamed By User' } })
)

await GET(
authorizeRequest({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: CREDENTIAL_ID,
})
)

expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({ displayName: 'Renamed By User' })
)
})

it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => {
for (const providerId of ['trello', 'shopify']) {
const response = await GET(
authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID })
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=credential_reconnect_unsupported`
)
}
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
expect(dbChainMockFns.values).not.toHaveBeenCalled()
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
})

it('rejects when the caller is not a credential admin and writes no draft', async () => {
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))

const response = await GET(
authorizeRequest({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: CREDENTIAL_ID,
})
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=credential_access_denied`
)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
})

it('rejects when the credential belongs to a different workspace', async () => {
mockGetCredentialActorContext.mockResolvedValue(
oauthCredentialActor({ credential: { workspaceId: 'ws-other' } })
)

const response = await GET(
authorizeRequest({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: CREDENTIAL_ID,
})
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=credential_access_denied`
)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
})

it('rejects when the credential does not exist', async () => {
mockGetCredentialActorContext.mockResolvedValue({
credential: null,
member: null,
hasWorkspaceAccess: false,
canWriteWorkspace: false,
isAdmin: false,
})

const response = await GET(
authorizeRequest({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: 'cred-missing',
})
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=credential_access_denied`
)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
})

it('rejects a non-oauth credential', async () => {
mockGetCredentialActorContext.mockResolvedValue(
oauthCredentialActor({ credential: { type: 'env_workspace' } })
)

const response = await GET(
authorizeRequest({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: CREDENTIAL_ID,
})
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=credential_access_denied`
)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
})

it('rejects when the query providerId does not match the credential provider', async () => {
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())

const response = await GET(
authorizeRequest({
providerId: 'slack',
workspaceId: WORKSPACE_ID,
credentialId: CREDENTIAL_ID,
})
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/workspace?error=credential_provider_mismatch`
)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
})
})
})
Loading
Loading