Skip to content

Commit a7c8f5d

Browse files
authored
fix(chat-deploy): fixed permissions to match the workspace permissions, admins can deploy & edit & delete (#753)
* fix(chat-deploy): fixed permissions to match the workspace permissions, admins can deploy & edit & delete * fixed hanging chat deploy modal * remove unnecessary fallback
1 parent e392ca4 commit a7c8f5d

File tree

7 files changed

+681
-166
lines changed

7 files changed

+681
-166
lines changed
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
import { NextRequest } from 'next/server'
2+
/**
3+
* Tests for chat edit API route
4+
*
5+
* @vitest-environment node
6+
*/
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
describe('Chat Edit API Route', () => {
10+
const mockSelect = vi.fn()
11+
const mockFrom = vi.fn()
12+
const mockWhere = vi.fn()
13+
const mockLimit = vi.fn()
14+
const mockUpdate = vi.fn()
15+
const mockSet = vi.fn()
16+
const mockDelete = vi.fn()
17+
18+
const mockCreateSuccessResponse = vi.fn()
19+
const mockCreateErrorResponse = vi.fn()
20+
const mockEncryptSecret = vi.fn()
21+
const mockCheckChatAccess = vi.fn()
22+
23+
beforeEach(() => {
24+
vi.resetModules()
25+
26+
mockSelect.mockReturnValue({ from: mockFrom })
27+
mockFrom.mockReturnValue({ where: mockWhere })
28+
mockWhere.mockReturnValue({ limit: mockLimit })
29+
mockUpdate.mockReturnValue({ set: mockSet })
30+
mockSet.mockReturnValue({ where: mockWhere })
31+
mockDelete.mockReturnValue({ where: mockWhere })
32+
33+
vi.doMock('@/db', () => ({
34+
db: {
35+
select: mockSelect,
36+
update: mockUpdate,
37+
delete: mockDelete,
38+
},
39+
}))
40+
41+
vi.doMock('@/db/schema', () => ({
42+
chat: { id: 'id', subdomain: 'subdomain', userId: 'userId' },
43+
}))
44+
45+
vi.doMock('@/lib/logs/console-logger', () => ({
46+
createLogger: vi.fn().mockReturnValue({
47+
info: vi.fn(),
48+
error: vi.fn(),
49+
warn: vi.fn(),
50+
debug: vi.fn(),
51+
}),
52+
}))
53+
54+
vi.doMock('@/app/api/workflows/utils', () => ({
55+
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
56+
return new Response(JSON.stringify(data), {
57+
status: 200,
58+
headers: { 'Content-Type': 'application/json' },
59+
})
60+
}),
61+
createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => {
62+
return new Response(JSON.stringify({ error: message }), {
63+
status,
64+
headers: { 'Content-Type': 'application/json' },
65+
})
66+
}),
67+
}))
68+
69+
vi.doMock('@/lib/utils', () => ({
70+
encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }),
71+
}))
72+
73+
vi.doMock('@/lib/urls/utils', () => ({
74+
getBaseDomain: vi.fn().mockReturnValue('localhost:3000'),
75+
}))
76+
77+
vi.doMock('@/lib/environment', () => ({
78+
isDev: true,
79+
}))
80+
81+
vi.doMock('@/app/api/chat/utils', () => ({
82+
checkChatAccess: mockCheckChatAccess,
83+
}))
84+
})
85+
86+
afterEach(() => {
87+
vi.clearAllMocks()
88+
})
89+
90+
describe('GET', () => {
91+
it('should return 401 when user is not authenticated', async () => {
92+
vi.doMock('@/lib/auth', () => ({
93+
getSession: vi.fn().mockResolvedValue(null),
94+
}))
95+
96+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
97+
const { GET } = await import('./route')
98+
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
99+
100+
expect(response.status).toBe(401)
101+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
102+
})
103+
104+
it('should return 404 when chat not found or access denied', async () => {
105+
vi.doMock('@/lib/auth', () => ({
106+
getSession: vi.fn().mockResolvedValue({
107+
user: { id: 'user-id' },
108+
}),
109+
}))
110+
111+
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
112+
113+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
114+
const { GET } = await import('./route')
115+
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
116+
117+
expect(response.status).toBe(404)
118+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
119+
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
120+
})
121+
122+
it('should return chat details when user has access', async () => {
123+
vi.doMock('@/lib/auth', () => ({
124+
getSession: vi.fn().mockResolvedValue({
125+
user: { id: 'user-id' },
126+
}),
127+
}))
128+
129+
const mockChat = {
130+
id: 'chat-123',
131+
subdomain: 'test-chat',
132+
title: 'Test Chat',
133+
description: 'A test chat',
134+
password: 'encrypted-password',
135+
customizations: { primaryColor: '#000000' },
136+
}
137+
138+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
139+
140+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
141+
const { GET } = await import('./route')
142+
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
143+
144+
expect(response.status).toBe(200)
145+
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
146+
id: 'chat-123',
147+
subdomain: 'test-chat',
148+
title: 'Test Chat',
149+
description: 'A test chat',
150+
customizations: { primaryColor: '#000000' },
151+
chatUrl: 'http://test-chat.localhost:3000',
152+
hasPassword: true,
153+
})
154+
})
155+
})
156+
157+
describe('PATCH', () => {
158+
it('should return 401 when user is not authenticated', async () => {
159+
vi.doMock('@/lib/auth', () => ({
160+
getSession: vi.fn().mockResolvedValue(null),
161+
}))
162+
163+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
164+
method: 'PATCH',
165+
body: JSON.stringify({ title: 'Updated Chat' }),
166+
})
167+
const { PATCH } = await import('./route')
168+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
169+
170+
expect(response.status).toBe(401)
171+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
172+
})
173+
174+
it('should return 404 when chat not found or access denied', async () => {
175+
vi.doMock('@/lib/auth', () => ({
176+
getSession: vi.fn().mockResolvedValue({
177+
user: { id: 'user-id' },
178+
}),
179+
}))
180+
181+
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
182+
183+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
184+
method: 'PATCH',
185+
body: JSON.stringify({ title: 'Updated Chat' }),
186+
})
187+
const { PATCH } = await import('./route')
188+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
189+
190+
expect(response.status).toBe(404)
191+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
192+
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
193+
})
194+
195+
it('should update chat when user has access', async () => {
196+
vi.doMock('@/lib/auth', () => ({
197+
getSession: vi.fn().mockResolvedValue({
198+
user: { id: 'user-id' },
199+
}),
200+
}))
201+
202+
const mockChat = {
203+
id: 'chat-123',
204+
subdomain: 'test-chat',
205+
title: 'Test Chat',
206+
authType: 'public',
207+
}
208+
209+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
210+
211+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
212+
method: 'PATCH',
213+
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
214+
})
215+
const { PATCH } = await import('./route')
216+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
217+
218+
expect(response.status).toBe(200)
219+
expect(mockUpdate).toHaveBeenCalled()
220+
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
221+
id: 'chat-123',
222+
chatUrl: 'http://test-chat.localhost:3000',
223+
message: 'Chat deployment updated successfully',
224+
})
225+
})
226+
227+
it('should handle subdomain conflicts', async () => {
228+
vi.doMock('@/lib/auth', () => ({
229+
getSession: vi.fn().mockResolvedValue({
230+
user: { id: 'user-id' },
231+
}),
232+
}))
233+
234+
const mockChat = {
235+
id: 'chat-123',
236+
subdomain: 'test-chat',
237+
title: 'Test Chat',
238+
}
239+
240+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
241+
// Mock subdomain conflict
242+
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', subdomain: 'new-subdomain' }])
243+
244+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
245+
method: 'PATCH',
246+
body: JSON.stringify({ subdomain: 'new-subdomain' }),
247+
})
248+
const { PATCH } = await import('./route')
249+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
250+
251+
expect(response.status).toBe(400)
252+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
253+
})
254+
255+
it('should validate password requirement for password auth', async () => {
256+
vi.doMock('@/lib/auth', () => ({
257+
getSession: vi.fn().mockResolvedValue({
258+
user: { id: 'user-id' },
259+
}),
260+
}))
261+
262+
const mockChat = {
263+
id: 'chat-123',
264+
subdomain: 'test-chat',
265+
title: 'Test Chat',
266+
authType: 'public',
267+
password: null,
268+
}
269+
270+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
271+
272+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
273+
method: 'PATCH',
274+
body: JSON.stringify({ authType: 'password' }), // No password provided
275+
})
276+
const { PATCH } = await import('./route')
277+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
278+
279+
expect(response.status).toBe(400)
280+
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
281+
'Password is required when using password protection',
282+
400
283+
)
284+
})
285+
286+
it('should allow access when user has workspace admin permission', async () => {
287+
vi.doMock('@/lib/auth', () => ({
288+
getSession: vi.fn().mockResolvedValue({
289+
user: { id: 'admin-user-id' },
290+
}),
291+
}))
292+
293+
const mockChat = {
294+
id: 'chat-123',
295+
subdomain: 'test-chat',
296+
title: 'Test Chat',
297+
authType: 'public',
298+
}
299+
300+
// User doesn't own chat but has workspace admin access
301+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
302+
303+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
304+
method: 'PATCH',
305+
body: JSON.stringify({ title: 'Admin Updated Chat' }),
306+
})
307+
const { PATCH } = await import('./route')
308+
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
309+
310+
expect(response.status).toBe(200)
311+
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
312+
})
313+
})
314+
315+
describe('DELETE', () => {
316+
it('should return 401 when user is not authenticated', async () => {
317+
vi.doMock('@/lib/auth', () => ({
318+
getSession: vi.fn().mockResolvedValue(null),
319+
}))
320+
321+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
322+
method: 'DELETE',
323+
})
324+
const { DELETE } = await import('./route')
325+
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
326+
327+
expect(response.status).toBe(401)
328+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
329+
})
330+
331+
it('should return 404 when chat not found or access denied', async () => {
332+
vi.doMock('@/lib/auth', () => ({
333+
getSession: vi.fn().mockResolvedValue({
334+
user: { id: 'user-id' },
335+
}),
336+
}))
337+
338+
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
339+
340+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
341+
method: 'DELETE',
342+
})
343+
const { DELETE } = await import('./route')
344+
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
345+
346+
expect(response.status).toBe(404)
347+
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
348+
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
349+
})
350+
351+
it('should delete chat when user has access', async () => {
352+
vi.doMock('@/lib/auth', () => ({
353+
getSession: vi.fn().mockResolvedValue({
354+
user: { id: 'user-id' },
355+
}),
356+
}))
357+
358+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
359+
mockWhere.mockResolvedValue(undefined)
360+
361+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
362+
method: 'DELETE',
363+
})
364+
const { DELETE } = await import('./route')
365+
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
366+
367+
expect(response.status).toBe(200)
368+
expect(mockDelete).toHaveBeenCalled()
369+
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
370+
message: 'Chat deployment deleted successfully',
371+
})
372+
})
373+
374+
it('should allow deletion when user has workspace admin permission', async () => {
375+
vi.doMock('@/lib/auth', () => ({
376+
getSession: vi.fn().mockResolvedValue({
377+
user: { id: 'admin-user-id' },
378+
}),
379+
}))
380+
381+
// User doesn't own chat but has workspace admin access
382+
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
383+
mockWhere.mockResolvedValue(undefined)
384+
385+
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
386+
method: 'DELETE',
387+
})
388+
const { DELETE } = await import('./route')
389+
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
390+
391+
expect(response.status).toBe(200)
392+
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
393+
expect(mockDelete).toHaveBeenCalled()
394+
})
395+
})
396+
})

0 commit comments

Comments
 (0)