Skip to content

Commit 6a6c9c3

Browse files
committed
add preview routes and tests
1 parent 6f693c1 commit 6a6c9c3

7 files changed

Lines changed: 591 additions & 6 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
7+
8+
const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({
9+
mockGetSession: vi.fn(),
10+
mockVerifyWorkspaceMembership: vi.fn(),
11+
mockRunSandboxTask: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({
15+
getSession: mockGetSession,
16+
}))
17+
18+
vi.mock('@/app/api/workflows/utils', () => ({
19+
verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
20+
}))
21+
22+
vi.mock('@/lib/execution/sandbox/run-task', () => ({
23+
runSandboxTask: mockRunSandboxTask,
24+
}))
25+
26+
import { POST } from '@/app/api/workspaces/[id]/docx/preview/route'
27+
28+
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
29+
30+
describe('DOCX preview API route', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
34+
mockVerifyWorkspaceMembership.mockResolvedValue(true)
35+
mockRunSandboxTask.mockResolvedValue(Buffer.from('PK\x03\x04docx'))
36+
})
37+
38+
it('returns a generated DOCX for authorized workspace members', async () => {
39+
const request = new NextRequest(
40+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
41+
{
42+
method: 'POST',
43+
headers: {
44+
'Content-Type': 'application/json',
45+
},
46+
body: JSON.stringify({ code: 'return 1' }),
47+
}
48+
)
49+
50+
const response = await POST(request, {
51+
params: Promise.resolve({ id: 'workspace-1' }),
52+
})
53+
54+
expect(response.status).toBe(200)
55+
expect(response.headers.get('Content-Type')).toBe(DOCX_MIME)
56+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
57+
expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1')
58+
expect(mockRunSandboxTask).toHaveBeenCalledWith(
59+
'docx-generate',
60+
{ code: 'return 1', workspaceId: 'workspace-1' },
61+
{ ownerKey: 'user:user-1', signal: request.signal }
62+
)
63+
expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('PK\x03\x04docx')
64+
})
65+
66+
it('rejects requests without code', async () => {
67+
const request = new NextRequest(
68+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
69+
{
70+
method: 'POST',
71+
headers: {
72+
'Content-Type': 'application/json',
73+
},
74+
body: JSON.stringify({}),
75+
}
76+
)
77+
78+
const response = await POST(request, {
79+
params: Promise.resolve({ id: 'workspace-1' }),
80+
})
81+
82+
expect(response.status).toBe(400)
83+
await expect(response.json()).resolves.toEqual({ error: 'code is required' })
84+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
85+
})
86+
87+
it('rejects oversized preview source payloads', async () => {
88+
const request = new NextRequest(
89+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
90+
{
91+
method: 'POST',
92+
headers: {
93+
'Content-Type': 'application/json',
94+
},
95+
body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }),
96+
}
97+
)
98+
99+
const response = await POST(request, {
100+
params: Promise.resolve({ id: 'workspace-1' }),
101+
})
102+
103+
expect(response.status).toBe(413)
104+
await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' })
105+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
106+
})
107+
108+
it('returns 401 for unauthenticated requests', async () => {
109+
mockGetSession.mockResolvedValue(null)
110+
111+
const request = new NextRequest(
112+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
113+
{
114+
method: 'POST',
115+
headers: {
116+
'Content-Type': 'application/json',
117+
},
118+
body: JSON.stringify({ code: 'return 1' }),
119+
}
120+
)
121+
122+
const response = await POST(request, {
123+
params: Promise.resolve({ id: 'workspace-1' }),
124+
})
125+
126+
expect(response.status).toBe(401)
127+
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
128+
expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled()
129+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
130+
})
131+
132+
it('returns 403 when the user is not a workspace member', async () => {
133+
mockVerifyWorkspaceMembership.mockResolvedValue(false)
134+
135+
const request = new NextRequest(
136+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
137+
{
138+
method: 'POST',
139+
headers: {
140+
'Content-Type': 'application/json',
141+
},
142+
body: JSON.stringify({ code: 'return 1' }),
143+
}
144+
)
145+
146+
const response = await POST(request, {
147+
params: Promise.resolve({ id: 'workspace-1' }),
148+
})
149+
150+
expect(response.status).toBe(403)
151+
await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' })
152+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
153+
})
154+
155+
it('returns 400 for requests with invalid JSON bodies', async () => {
156+
const request = new NextRequest(
157+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
158+
{
159+
method: 'POST',
160+
headers: {
161+
'Content-Type': 'application/json',
162+
},
163+
body: '{ not valid json',
164+
}
165+
)
166+
167+
const response = await POST(request, {
168+
params: Promise.resolve({ id: 'workspace-1' }),
169+
})
170+
171+
expect(response.status).toBe(400)
172+
await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' })
173+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
174+
})
175+
176+
it('returns 500 when DOCX generation throws', async () => {
177+
mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed'))
178+
179+
const request = new NextRequest(
180+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
181+
{
182+
method: 'POST',
183+
headers: {
184+
'Content-Type': 'application/json',
185+
},
186+
body: JSON.stringify({ code: 'return 1' }),
187+
}
188+
)
189+
190+
const response = await POST(request, {
191+
params: Promise.resolve({ id: 'workspace-1' }),
192+
})
193+
194+
expect(response.status).toBe(500)
195+
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
196+
})
197+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { toError } from '@/lib/core/utils/helpers'
5+
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
6+
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
7+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
8+
9+
export const dynamic = 'force-dynamic'
10+
export const runtime = 'nodejs'
11+
12+
const logger = createLogger('DocxPreviewAPI')
13+
14+
/**
15+
* POST /api/workspaces/[id]/docx/preview
16+
* Compile docx source code and return the binary DOCX for streaming preview.
17+
*/
18+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
19+
const { id: workspaceId } = await params
20+
21+
try {
22+
const session = await getSession()
23+
if (!session?.user?.id) {
24+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
25+
}
26+
27+
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
28+
if (!membership) {
29+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
30+
}
31+
32+
let body: unknown
33+
try {
34+
body = await req.json()
35+
} catch {
36+
return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 })
37+
}
38+
const { code } = body as { code?: string }
39+
40+
if (typeof code !== 'string' || code.trim().length === 0) {
41+
return NextResponse.json({ error: 'code is required' }, { status: 400 })
42+
}
43+
44+
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
45+
return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 })
46+
}
47+
48+
const buffer = await runSandboxTask(
49+
'docx-generate',
50+
{ code, workspaceId },
51+
{ ownerKey: `user:${session.user.id}`, signal: req.signal }
52+
)
53+
54+
return new NextResponse(new Uint8Array(buffer), {
55+
status: 200,
56+
headers: {
57+
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
58+
'Content-Length': String(buffer.length),
59+
'Cache-Control': 'private, no-store',
60+
},
61+
})
62+
} catch (err) {
63+
const message = toError(err).message
64+
logger.error('DOCX preview generation failed', { error: message, workspaceId })
65+
return NextResponse.json({ error: message }, { status: 500 })
66+
}
67+
}

apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { NextRequest } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
67

78
const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({
89
mockGetSession: vi.fn(),
@@ -80,4 +81,115 @@ describe('PDF preview API route', () => {
8081
await expect(response.json()).resolves.toEqual({ error: 'code is required' })
8182
expect(mockRunSandboxTask).not.toHaveBeenCalled()
8283
})
84+
85+
it('rejects oversized preview source payloads', async () => {
86+
const request = new NextRequest(
87+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
88+
{
89+
method: 'POST',
90+
headers: {
91+
'Content-Type': 'application/json',
92+
},
93+
body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }),
94+
}
95+
)
96+
97+
const response = await POST(request, {
98+
params: Promise.resolve({ id: 'workspace-1' }),
99+
})
100+
101+
expect(response.status).toBe(413)
102+
await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' })
103+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
104+
})
105+
106+
it('returns 401 for unauthenticated requests', async () => {
107+
mockGetSession.mockResolvedValue(null)
108+
109+
const request = new NextRequest(
110+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
111+
{
112+
method: 'POST',
113+
headers: {
114+
'Content-Type': 'application/json',
115+
},
116+
body: JSON.stringify({ code: 'return 1' }),
117+
}
118+
)
119+
120+
const response = await POST(request, {
121+
params: Promise.resolve({ id: 'workspace-1' }),
122+
})
123+
124+
expect(response.status).toBe(401)
125+
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
126+
expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled()
127+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
128+
})
129+
130+
it('returns 403 when the user is not a workspace member', async () => {
131+
mockVerifyWorkspaceMembership.mockResolvedValue(false)
132+
133+
const request = new NextRequest(
134+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
135+
{
136+
method: 'POST',
137+
headers: {
138+
'Content-Type': 'application/json',
139+
},
140+
body: JSON.stringify({ code: 'return 1' }),
141+
}
142+
)
143+
144+
const response = await POST(request, {
145+
params: Promise.resolve({ id: 'workspace-1' }),
146+
})
147+
148+
expect(response.status).toBe(403)
149+
await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' })
150+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
151+
})
152+
153+
it('returns 400 for requests with invalid JSON bodies', async () => {
154+
const request = new NextRequest(
155+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
156+
{
157+
method: 'POST',
158+
headers: {
159+
'Content-Type': 'application/json',
160+
},
161+
body: '{ not valid json',
162+
}
163+
)
164+
165+
const response = await POST(request, {
166+
params: Promise.resolve({ id: 'workspace-1' }),
167+
})
168+
169+
expect(response.status).toBe(400)
170+
await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' })
171+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
172+
})
173+
174+
it('returns 500 when PDF generation throws', async () => {
175+
mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed'))
176+
177+
const request = new NextRequest(
178+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
179+
{
180+
method: 'POST',
181+
headers: {
182+
'Content-Type': 'application/json',
183+
},
184+
body: JSON.stringify({ code: 'return 1' }),
185+
}
186+
)
187+
188+
const response = await POST(request, {
189+
params: Promise.resolve({ id: 'workspace-1' }),
190+
})
191+
192+
expect(response.status).toBe(500)
193+
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
194+
})
83195
})

0 commit comments

Comments
 (0)