Skip to content

Commit dd83be8

Browse files
committed
feat(credentials): agent-initiated oauth credential reconnect
1 parent f97899d commit dd83be8

12 files changed

Lines changed: 809 additions & 86 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockGetSession,
9+
mockOAuth2LinkAccount,
10+
mockCheckWorkspaceAccess,
11+
mockGetCredentialActorContext,
12+
} = vi.hoisted(() => ({
13+
mockGetSession: vi.fn(),
14+
mockOAuth2LinkAccount: vi.fn(),
15+
mockCheckWorkspaceAccess: vi.fn(),
16+
mockGetCredentialActorContext: vi.fn(),
17+
}))
18+
19+
vi.mock('@sim/db', () => dbChainMock)
20+
21+
vi.mock('@/lib/auth/auth', () => ({
22+
auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
23+
getSession: mockGetSession,
24+
}))
25+
26+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
27+
checkWorkspaceAccess: mockCheckWorkspaceAccess,
28+
}))
29+
30+
vi.mock('@/lib/credentials/access', () => ({
31+
getCredentialActorContext: mockGetCredentialActorContext,
32+
}))
33+
34+
vi.mock('@/lib/oauth/utils', () => ({
35+
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
36+
}))
37+
38+
import { GET } from '@/app/api/auth/oauth2/authorize/route'
39+
40+
const BASE_URL = 'https://sim.test'
41+
const WORKSPACE_ID = 'ws-1'
42+
const USER_ID = 'user-1'
43+
const CREDENTIAL_ID = 'cred-1'
44+
const LINK_URL = 'https://provider.example/authorize?state=abc'
45+
46+
function authorizeRequest(query: Record<string, string>) {
47+
const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
48+
for (const [key, value] of Object.entries(query)) {
49+
url.searchParams.set(key, value)
50+
}
51+
return createMockRequest('GET', undefined, {}, url.toString())
52+
}
53+
54+
function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
55+
return {
56+
credential: {
57+
id: CREDENTIAL_ID,
58+
workspaceId: WORKSPACE_ID,
59+
type: 'oauth',
60+
providerId: 'google-email',
61+
...((overrides.credential as Record<string, unknown>) ?? {}),
62+
},
63+
member: null,
64+
hasWorkspaceAccess: true,
65+
canWriteWorkspace: true,
66+
isAdmin: true,
67+
...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')),
68+
}
69+
}
70+
71+
describe('OAuth2 authorize route', () => {
72+
beforeEach(() => {
73+
vi.clearAllMocks()
74+
resetDbChainMock()
75+
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
76+
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
77+
mockCheckWorkspaceAccess.mockResolvedValue({
78+
hasAccess: true,
79+
canWrite: true,
80+
canAdmin: false,
81+
workspace: { id: WORKSPACE_ID },
82+
})
83+
mockOAuth2LinkAccount.mockResolvedValue({
84+
ok: true,
85+
status: 200,
86+
json: async () => ({ url: LINK_URL }),
87+
headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] },
88+
})
89+
})
90+
91+
describe('plain connect (no credentialId)', () => {
92+
it('creates a draft with credentialId null and redirects to the provider', async () => {
93+
const response = await GET(
94+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
95+
)
96+
97+
expect(response.headers.get('location')).toBe(LINK_URL)
98+
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
99+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
100+
expect.objectContaining({
101+
userId: USER_ID,
102+
workspaceId: WORKSPACE_ID,
103+
providerId: 'google-email',
104+
credentialId: null,
105+
})
106+
)
107+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
108+
expect.objectContaining({
109+
set: expect.objectContaining({ credentialId: null }),
110+
})
111+
)
112+
})
113+
114+
it('numbers the draft display name when the default collides with an existing credential', async () => {
115+
dbChainMockFns.where
116+
.mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }]))
117+
.mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }]))
118+
119+
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
120+
121+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
122+
expect.objectContaining({ displayName: "Justin's Gmail 2" })
123+
)
124+
})
125+
126+
it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => {
127+
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
128+
129+
const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0]
130+
expect(set).toHaveProperty('credentialId', null)
131+
})
132+
133+
it('redirects to login when unauthenticated', async () => {
134+
mockGetSession.mockResolvedValue(null)
135+
136+
const response = await GET(
137+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
138+
)
139+
140+
expect(response.headers.get('location')).toContain('/login')
141+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
142+
})
143+
144+
it('rejects without workspace write access', async () => {
145+
mockCheckWorkspaceAccess.mockResolvedValue({
146+
hasAccess: true,
147+
canWrite: false,
148+
canAdmin: false,
149+
workspace: { id: WORKSPACE_ID },
150+
})
151+
152+
const response = await GET(
153+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
154+
)
155+
156+
expect(response.headers.get('location')).toBe(
157+
`${BASE_URL}/workspace?error=workspace_access_denied`
158+
)
159+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
160+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
161+
})
162+
})
163+
164+
describe('reconnect (credentialId present)', () => {
165+
it('creates a reconnect draft carrying credentialId in values and upsert set', async () => {
166+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
167+
168+
const response = await GET(
169+
authorizeRequest({
170+
providerId: 'google-email',
171+
workspaceId: WORKSPACE_ID,
172+
credentialId: CREDENTIAL_ID,
173+
})
174+
)
175+
176+
expect(response.headers.get('location')).toBe(LINK_URL)
177+
expect(mockGetCredentialActorContext).toHaveBeenCalledWith(
178+
CREDENTIAL_ID,
179+
USER_ID,
180+
expect.objectContaining({ workspaceAccess: expect.anything() })
181+
)
182+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
183+
expect.objectContaining({ credentialId: CREDENTIAL_ID })
184+
)
185+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
186+
expect.objectContaining({
187+
set: expect.objectContaining({ credentialId: CREDENTIAL_ID }),
188+
})
189+
)
190+
})
191+
192+
it('rejects when the caller is not a credential admin and writes no draft', async () => {
193+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))
194+
195+
const response = await GET(
196+
authorizeRequest({
197+
providerId: 'google-email',
198+
workspaceId: WORKSPACE_ID,
199+
credentialId: CREDENTIAL_ID,
200+
})
201+
)
202+
203+
expect(response.headers.get('location')).toBe(
204+
`${BASE_URL}/workspace?error=credential_access_denied`
205+
)
206+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
207+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
208+
})
209+
210+
it('rejects when the credential belongs to a different workspace', async () => {
211+
mockGetCredentialActorContext.mockResolvedValue(
212+
oauthCredentialActor({ credential: { workspaceId: 'ws-other' } })
213+
)
214+
215+
const response = await GET(
216+
authorizeRequest({
217+
providerId: 'google-email',
218+
workspaceId: WORKSPACE_ID,
219+
credentialId: CREDENTIAL_ID,
220+
})
221+
)
222+
223+
expect(response.headers.get('location')).toBe(
224+
`${BASE_URL}/workspace?error=credential_access_denied`
225+
)
226+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
227+
})
228+
229+
it('rejects when the credential does not exist', async () => {
230+
mockGetCredentialActorContext.mockResolvedValue({
231+
credential: null,
232+
member: null,
233+
hasWorkspaceAccess: false,
234+
canWriteWorkspace: false,
235+
isAdmin: false,
236+
})
237+
238+
const response = await GET(
239+
authorizeRequest({
240+
providerId: 'google-email',
241+
workspaceId: WORKSPACE_ID,
242+
credentialId: 'cred-missing',
243+
})
244+
)
245+
246+
expect(response.headers.get('location')).toBe(
247+
`${BASE_URL}/workspace?error=credential_access_denied`
248+
)
249+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
250+
})
251+
252+
it('rejects a non-oauth credential', async () => {
253+
mockGetCredentialActorContext.mockResolvedValue(
254+
oauthCredentialActor({ credential: { type: 'env_workspace' } })
255+
)
256+
257+
const response = await GET(
258+
authorizeRequest({
259+
providerId: 'google-email',
260+
workspaceId: WORKSPACE_ID,
261+
credentialId: CREDENTIAL_ID,
262+
})
263+
)
264+
265+
expect(response.headers.get('location')).toBe(
266+
`${BASE_URL}/workspace?error=credential_access_denied`
267+
)
268+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
269+
})
270+
271+
it('rejects when the query providerId does not match the credential provider', async () => {
272+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
273+
274+
const response = await GET(
275+
authorizeRequest({
276+
providerId: 'slack',
277+
workspaceId: WORKSPACE_ID,
278+
credentialId: CREDENTIAL_ID,
279+
})
280+
)
281+
282+
expect(response.headers.get('location')).toBe(
283+
`${BASE_URL}/workspace?error=credential_provider_mismatch`
284+
)
285+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
286+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
287+
})
288+
})
289+
})

0 commit comments

Comments
 (0)