Skip to content
Merged
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
154 changes: 154 additions & 0 deletions apps/sim/app/api/tools/google_drive/download/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'

vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)

import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { POST } from '@/app/api/tools/google_drive/download/route'

const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns

const PINNED_IP = '93.184.216.34'

const baseBody = {
accessToken: 'token-123',
fileId: 'file-abc',
}

function jsonResponse(body: unknown, ok = true) {
return {
ok,
status: ok ? 200 : 400,
statusText: '',
headers: new Headers(),
body: null,
text: async () => JSON.stringify(body),
json: async () => body,
arrayBuffer: async () => new ArrayBuffer(0),
}
}

function fileResponse(bytes: number, ok = true) {
return {
ok,
status: ok ? 200 : 400,
statusText: '',
headers: new Headers(),
body: null,
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(bytes),
}
}

beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'www.googleapis.com',
})
})

describe('POST /api/tools/google_drive/download', () => {
it('downloads a normal file under the size cap', async () => {
mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(
jsonResponse({
id: 'file-abc',
name: 'report.pdf',
mimeType: 'application/pdf',
size: '1024',
capabilities: { canReadRevisions: false },
})
)
.mockResolvedValueOnce(fileResponse(1024))

const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(200)
const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
expect(data.success).toBe(true)
expect(data.output.file.size).toBe(1024)

const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1]
expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
})

it('rejects the download before fetching content when metadata size exceeds the cap', async () => {
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
jsonResponse({
id: 'file-abc',
name: 'huge.bin',
mimeType: 'application/octet-stream',
size: String(MAX_FILE_SIZE + 1),
capabilities: { canReadRevisions: false },
})
)

const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(413)
const data = (await response.json()) as { success: boolean; error: string }
expect(data.success).toBe(false)
expect(data.error).toContain('exceeds maximum size')

// Content download must never be initiated once metadata size trips the check.
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
})

it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(
jsonResponse({
id: 'file-abc',
name: 'report.pdf',
mimeType: 'application/pdf',
capabilities: { canReadRevisions: false },
})
)
.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'response body',
maxBytes: MAX_FILE_SIZE,
observedBytes: MAX_FILE_SIZE + 1,
})
)

const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(413)
const data = (await response.json()) as { success: boolean }
expect(data.success).toBe(false)
})

it('does not require a metadata size for Google Workspace exports', async () => {
mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(
jsonResponse({
id: 'doc-1',
name: 'My Doc',
mimeType: 'application/vnd.google-apps.document',
capabilities: { canReadRevisions: false },
})
)
.mockResolvedValueOnce(fileResponse(2048))

const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(200)

const exportCall = mockSecureFetchWithPinnedIP.mock.calls[1]
expect(exportCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
})
})
16 changes: 13 additions & 3 deletions apps/sim/app/api/tools/google_drive/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import type { GoogleDriveFile, GoogleDriveRevision } from '@/tools/google_drive/types'
import {
ALL_FILE_FIELDS,
Expand Down Expand Up @@ -160,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const exportResponse = await secureFetchWithPinnedIP(
exportUrl,
exportUrlValidation.resolvedIP!,
{ headers: { Authorization: authHeader } }
{ headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE }
)

if (!exportResponse.ok) {
Expand All @@ -185,6 +187,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} else {
logger.info(`[${requestId}] Downloading regular file`, { fileId, mimeType: fileMimeType })

if (metadata.size) {
assertKnownSizeWithinLimit(
Number.parseInt(metadata.size, 10),
MAX_FILE_SIZE,
`Google Drive file ${fileId}`
)
}

const downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true`
const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
if (!downloadUrlValidation.isValid) {
Expand All @@ -197,7 +207,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const downloadResponse = await secureFetchWithPinnedIP(
downloadUrl,
downloadUrlValidation.resolvedIP!,
{ headers: { Authorization: authHeader } }
{ headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE }
)

if (!downloadResponse.ok) {
Expand Down Expand Up @@ -274,7 +284,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
success: false,
error: getErrorMessage(error, 'Unknown error occurred'),
},
{ status: 500 }
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
)
}
})
107 changes: 107 additions & 0 deletions apps/sim/app/api/tools/onedrive/download/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'

vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)

import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { POST } from '@/app/api/tools/onedrive/download/route'

const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns

const PINNED_IP = '93.184.216.34'

const baseBody = {
accessToken: 'token-123',
fileId: 'file-abc',
}

function jsonResponse(body: unknown, ok = true) {
return {
ok,
status: ok ? 200 : 400,
statusText: '',
headers: new Headers(),
body: null,
text: async () => JSON.stringify(body),
json: async () => body,
arrayBuffer: async () => new ArrayBuffer(0),
}
}

function fileResponse(bytes: number) {
return {
ok: true,
status: 200,
statusText: '',
headers: new Headers(),
body: null,
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(bytes),
}
}

beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'graph.microsoft.com',
})
})

describe('POST /api/tools/onedrive/download', () => {
it('downloads a normal file under the size cap', async () => {
mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(
jsonResponse({ id: 'file-abc', name: 'report.pdf', file: { mimeType: 'application/pdf' } })
)
.mockResolvedValueOnce(fileResponse(1024))

const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(200)
const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
expect(data.success).toBe(true)
expect(data.output.file.size).toBe(1024)

const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1]
expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
})

it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(
jsonResponse({
id: 'file-abc',
name: 'huge.bin',
file: { mimeType: 'application/octet-stream' },
})
)
.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'response body',
maxBytes: MAX_FILE_SIZE,
observedBytes: MAX_FILE_SIZE + 1,
})
)

const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(413)
const data = (await response.json()) as { success: boolean }
expect(data.success).toBe(false)
})
})
5 changes: 4 additions & 1 deletion apps/sim/app/api/tools/onedrive/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -120,6 +122,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
downloadUrlValidation.resolvedIP!,
{
headers: { Authorization: authHeader },
maxResponseBytes: MAX_FILE_SIZE,
}
)

Expand Down Expand Up @@ -167,7 +170,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
success: false,
error: getErrorMessage(error, 'Unknown error occurred'),
},
{ status: 500 }
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
)
}
})
Loading
Loading