Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 44 additions & 2 deletions apps/sim/app/api/table/[tableId]/columns/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ vi.mock('@/lib/table', () => ({
updateColumnType: mockUpdateColumnType,
}))
vi.mock('@/app/api/table/utils', () => ({
accessError: () => new Response('denied', { status: 403 }),
accessError: (result: { status: number }) => new Response('denied', { status: result.status }),
checkAccess: mockCheckAccess,
normalizeColumn: (c: unknown) => c,
rootErrorMessage: (e: unknown) => getErrorMessage(e),
tableLockErrorResponse: () => null,
}))

import { PATCH } from '@/app/api/table/[tableId]/columns/route'
import { DELETE, PATCH, POST } from '@/app/api/table/[tableId]/columns/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'

Expand Down Expand Up @@ -83,6 +83,48 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
mockRenameColumn.mockResolvedValue({ schema: { columns: [] } })
})

it('rejects synthetic Memory column writes with a read-only explanation', async () => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
const tableId = `system_memory_${WORKSPACE_ID}`
const response = await PATCH(
new NextRequest(`http://localhost/api/table/${tableId}/columns`, {
method: 'PATCH',
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
columnName: 'transcript',
updates: { name: 'Messages' },
}),
headers: { 'content-type': 'application/json' },
}),
{ params: Promise.resolve({ tableId }) }
)

expect(response.status).toBe(423)
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
expect(mockRenameColumn).not.toHaveBeenCalled()
})

it.each([
['POST', POST, { workspaceId: WORKSPACE_ID, column: { name: 'Extra', type: 'string' } }],
['DELETE', DELETE, { workspaceId: WORKSPACE_ID, columnName: 'transcript' }],
])('rejects synthetic Memory %s writes through shared access', async (_method, handler, body) => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
const tableId = `system_memory_${WORKSPACE_ID}`
const response = await handler(
new NextRequest(`http://localhost/api/table/${tableId}/columns`, {
method: _method,
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
}),
{ params: Promise.resolve({ tableId }) }
)

expect(response.status).toBe(423)
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
expect(mockAddTableColumn).not.toHaveBeenCalled()
expect(mockDeleteColumn).not.toHaveBeenCalled()
})

it('rejects a currency code on a non-currency column without renaming first', async () => {
const response = await patch({ name: 'renamed', currencyCode: 'USD' })

Expand Down
65 changes: 64 additions & 1 deletion apps/sim/app/api/table/[tableId]/export/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'
import { encodeCursor } from '@/lib/table/rows/cursor'

const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
Expand All @@ -24,7 +25,7 @@ vi.mock('@/lib/table/rows/service', () => ({
queryRows: mockQueryRows,
}))

import { GET } from '@/app/api/table/[tableId]/export/route'
import { GET, HEAD } from '@/app/api/table/[tableId]/export/route'

/** Table with an id-native column whose stable id (`col_email`) differs from its display name. */
function buildTable(): TableDefinition {
Expand Down Expand Up @@ -56,6 +57,13 @@ function callGet(format: string) {
return GET(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}

function callHead(format: string) {
const req = new NextRequest(`http://localhost:3000/api/table/tbl_1/export?format=${format}`, {
method: 'HEAD',
})
return HEAD(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}

describe('table export route — id→name translation', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -92,4 +100,59 @@ describe('table export route — id→name translation', () => {
expect(parsed).toEqual([{ email: 'a@b.c', legacy: 'x' }])
expect(JSON.stringify(parsed)).not.toContain('col_email')
})

it('preflights authorization without starting the export query', async () => {
const res = await callHead('csv')

expect(res.status).toBe(204)
expect(mockCheckAccess).toHaveBeenCalledWith('tbl_1', 'user-1', 'read')
expect(mockQueryRows).not.toHaveBeenCalled()
})

it('continues a virtual export with the keyset cursor returned by the previous page', async () => {
const firstRow = {
id: 'r1',
data: { col_email: 'first@b.c', legacy: 'x' },
executions: {},
position: 0,
orderKey: '2026-01-02T00:00:00.000Z',
}
const nextCursor = encodeCursor({
lastRow: firstRow,
keysetValid: true,
nextOffset: 1,
})
mockQueryRows
.mockResolvedValueOnce({
rows: [firstRow],
rowCount: 1,
totalCount: null,
limit: 1000,
offset: 0,
nextCursor,
})
.mockResolvedValueOnce({
rows: [],
rowCount: 0,
totalCount: null,
limit: 1000,
offset: 0,
nextCursor: null,
})

const res = await callGet('csv')
await res.text()

expect(mockQueryRows).toHaveBeenNthCalledWith(
2,
expect.anything(),
{
limit: 1000,
offset: 0,
after: { orderKey: firstRow.orderKey, id: firstRow.id },
includeTotal: false,
},
expect.any(String)
)
})
})
22 changes: 20 additions & 2 deletions apps/sim/app/api/table/[tableId]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { captureServerEvent } from '@/lib/posthog/server'
import { namedRowMapper } from '@/lib/table/cell-format'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvCell } from '@/lib/table/export-format'
import { decodeCursor } from '@/lib/table/rows/cursor'
import { queryRows } from '@/lib/table/rows/service'
import { accessError, checkAccess } from '@/app/api/table/utils'

Expand All @@ -24,6 +25,20 @@ interface RouteParams {
params: Promise<{ tableId: string }>
}

/** HEAD /api/table/[tableId]/export - Validates download access before browser navigation. */
export const HEAD = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { tableId } = tableIdParamsSchema.parse(await params)
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const access = await checkAccess(tableId, auth.userId, 'read')
if (!access.ok) return accessError(access, requestId, tableId)
return new NextResponse(null, { status: 204, headers: { 'Cache-Control': 'no-store' } })
})

Comment thread
kerrylu marked this conversation as resolved.
/** GET /api/table/[tableId]/export - Streams the full table contents as CSV or JSON. */
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
Expand Down Expand Up @@ -92,11 +107,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
}

let offset = 0
let after: { orderKey: string | null; id: string } | undefined
let firstJsonRow = true
while (true) {
const result = await queryRows(
table,
{ limit: EXPORT_BATCH_SIZE, offset, includeTotal: false },
{ limit: EXPORT_BATCH_SIZE, offset, after, includeTotal: false },
requestId
)

Expand All @@ -114,7 +130,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
// A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE,
// so a short page does NOT mean the export is done — only a null cursor does.
if (!result.nextCursor) break
offset += result.rows.length
const decoded = decodeCursor(result.nextCursor)
after = decoded.after
offset = decoded.offset ?? 0
}

if (format === 'json') controller.enqueue(encoder.encode(']'))
Expand Down
55 changes: 55 additions & 0 deletions apps/sim/app/api/table/[tableId]/metadata/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckAccess, mockUpdateTableMetadata } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockUpdateTableMetadata: vi.fn(),
}))

vi.mock('@/lib/table', () => ({
updateTableMetadata: mockUpdateTableMetadata,
}))

vi.mock('@/app/api/table/utils', () => ({
accessError: (result: { status: number }) => new Response('denied', { status: result.status }),
checkAccess: mockCheckAccess,
}))

import { PUT } from '@/app/api/table/[tableId]/metadata/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'

describe('PUT /api/table/[tableId]/metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
})

it('rejects synthetic Memory metadata writes through shared access', async () => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
const tableId = `system_memory_${WORKSPACE_ID}`
const response = await PUT(
new NextRequest(`http://localhost/api/table/${tableId}/metadata`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
metadata: { columnWidths: { transcript: 320 } },
}),
}),
{ params: Promise.resolve({ tableId }) }
)

expect(response.status).toBe(423)
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
expect(mockUpdateTableMetadata).not.toHaveBeenCalled()
})
})
Loading