Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
7054655
feat(scout): add scout agent
Sg312 Jul 3, 2026
15030e5
fix(contracts): update contracts to include scout agent
Sg312 Jul 3, 2026
d009bac
feat(copilot): search agent (research+scout merge) + read-only table/…
Sg312 Jul 3, 2026
62f79da
feat(copilot): run_code compute-only handler; docs lint fix
Sg312 Jul 3, 2026
535917e
fix(copilot): failed tool calls must surface their error in terminal …
Sg312 Jul 3, 2026
8b9f63d
feat(chat): render inline question tags from the agent in chat
emir-karabeg Jul 4, 2026
32800ea
fix(chat): let inert multi-step questions browse all prompts
emir-karabeg Jul 4, 2026
2ffd560
improvement(chat): guard question answer formatting against sparse ar…
emir-karabeg Jul 4, 2026
35afe54
chore(copilot): drop user_memory from generated contracts and tool di…
Sg312 Jul 4, 2026
0a8facf
improvement(chat): answered question card becomes the user turn; two …
Sg312 Jul 4, 2026
a062537
improvement(chat): question cards are single_select only
Sg312 Jul 4, 2026
0bfd5aa
improvement(chat): bring back multi_select question cards
Sg312 Jul 4, 2026
13285ee
chore(copilot): regenerate mothership contract mirror (chat blob span…
Sg312 Jul 4, 2026
82c443e
chore(copilot): regenerate mothership contract mirror (chat blob metr…
Sg312 Jul 6, 2026
c28eb21
feat(secrets): make output of generate api key a secret
Sg312 Jul 7, 2026
323e27e
feat(cli): add mkdir, mv, cp to mship tool set
Sg312 Jul 8, 2026
089f089
feat(fork-chat): add fork chat to mothership
Sg312 Jul 8, 2026
b1a8ce8
fix(fork-chat): fix messageid handling in fork chat
Sg312 Jul 8, 2026
e4f3b5c
feat(credentials): agent-initiated oauth credential reconnect (#5488)
j15z Jul 8, 2026
2a5d238
fix(conflicts): remove migration
Sg312 Jul 8, 2026
ef0f32d
fix(conflicts): fix conflicts
Sg312 Jul 8, 2026
a44548b
fix(fork-chat): add migrations back
Sg312 Jul 8, 2026
b671ffe
fix(ci): fix lint
Sg312 Jul 8, 2026
fd80bfd
fix(ci): fix bad import
Sg312 Jul 8, 2026
780b572
fix(vfs): fix 500 char limit in vfs for skills and custom tools
Sg312 Jul 9, 2026
3826424
feat(copilot): gate user skills to explicit slash-attach (#5536)
j15z Jul 9, 2026
9b2d323
feat(lots-of-things): lots of things
Sg312 Jul 9, 2026
6c248b0
feat(subagents): add persistent subagents
Sg312 Jul 10, 2026
e717d4b
fix(copilot): let edit_workflow set knowledge-base tag filters, and s…
j15z Jul 10, 2026
5964bcf
feat(changes): huge changes
Sg312 Jul 10, 2026
645820c
fix(subagents): lanes
Sg312 Jul 10, 2026
315ab32
fix(mship): transcript stuff
Sg312 Jul 10, 2026
bc42612
fix(subagents): thinking lanes
Sg312 Jul 10, 2026
f5e3b5e
fix(superagent): fix superagent tools and checkpoints
Sg312 Jul 10, 2026
ddeccc2
fix(scope): scope subagent tools
Sg312 Jul 10, 2026
1c41f64
fix(lint): fix lint
Sg312 Jul 10, 2026
c8040cc
chore(db): regenerate workspace_files.message_id migration as 0260 on…
Sg312 Jul 11, 2026
c6b73c2
fix(superagent): fix superagent integration tools
Sg312 Jul 11, 2026
9234c2e
improvement(questions): make something else a placeholder
Sg312 Jul 11, 2026
7e3739d
chore(copilot): regenerate mothership contract mirror after staging r…
Sg312 Jul 11, 2026
0d165ee
feat(mship): add external mcps to mship
Sg312 Jul 11, 2026
83cbf65
fix(ci): fix dev build
Sg312 Jul 12, 2026
ccc7135
fix(stream): show thinking text
Sg312 Jul 12, 2026
fc460d0
fix(ci): force redeploy
Sg312 Jul 12, 2026
00ec836
fix(mothership): keep chat forks outside workspace storage billing
icecrasher321 Jul 13, 2026
b5fc3b5
fix(uploads): restore listWorkspaceFiles throwOnError option dropped …
Sg312 Jul 13, 2026
a9ed503
fix(subagent-streaming): remove italics
Sg312 Jul 13, 2026
ba6c443
fix(mothership): treat subagent lanes closed by subagent_end as settl…
icecrasher321 Jul 14, 2026
f8ed99c
fix(ui): thinking loader and rool names
Sg312 Jul 14, 2026
acba0ff
fix(ui): add file
Sg312 Jul 14, 2026
a089f98
fix(thinking): show thinking during subagents
Sg312 Jul 14, 2026
bf9d014
fix(chat): drop dead thinking-channel ternary after lanes skip thinki…
Sg312 Jul 14, 2026
31945d0
fix(thinking): remove thinking text
Sg312 Jul 14, 2026
d3dfbd8
improvement(function execute): add timeout to function execute and st…
Sg312 Jul 14, 2026
6330ff8
fix(subagents): hide thinking text
Sg312 Jul 14, 2026
31aca74
fix(ff): move ff to go
Sg312 Jul 14, 2026
3bc4997
improvement(superagent): nuke superagent
Sg312 Jul 14, 2026
db91544
feat(main-agent): superagent into main agent
Sg312 Jul 15, 2026
0ab67ba
chore(db): regenerate workspace_files.message_id migration as 0262 on…
Sg312 Jul 16, 2026
f9975ba
fix(credentials): restore reconnect params on shared createConnectDraft
Sg312 Jul 16, 2026
477ab00
fix(migrations): rebase with staging
Sg312 Jul 16, 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
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