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
115 changes: 115 additions & 0 deletions apps/sim/app/api/tools/stt/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'

const { mockIsInternalFileUrl, mockDownloadFileFromStorage, mockResolveInternalFileUrl } =
vi.hoisted(() => ({
mockIsInternalFileUrl: vi.fn(),
mockDownloadFileFromStorage: vi.fn(),
mockResolveInternalFileUrl: vi.fn(),
}))

vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
vi.mock('@/lib/uploads/utils/file-utils', () => ({
isInternalFileUrl: mockIsInternalFileUrl,
getMimeTypeFromExtension: vi.fn(() => 'application/octet-stream'),
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
downloadFileFromStorage: mockDownloadFileFromStorage,
resolveInternalFileUrl: mockResolveInternalFileUrl,
}))
vi.mock('@/app/api/files/authorization', () => ({
assertToolFileAccess: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/audio/extractor', () => ({
isVideoFile: vi.fn(() => false),
extractAudioFromVideo: vi.fn(),
}))

import { POST } from '@/app/api/tools/stt/route'

const PINNED_IP = '93.184.216.34'

const baseBody = {
provider: 'whisper',
apiKey: 'test-api-key',
audioUrl: 'https://example.com/audio.mp3',
}

function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) {
return {
ok: body.ok ?? true,
status: 200,
statusText: '',
headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }),
body: null,
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(8),
}
}

describe('POST /api/tools/stt', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'example.com',
})
mockIsInternalFileUrl.mockReturnValue(false)

vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ text: 'hello world', language: 'en', duration: 1.2 }),
})
)
})

it('bounds the audioUrl download and rejects oversized responses cleanly', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'response body',
maxBytes: 100 * 1024 * 1024,
observedBytes: 200 * 1024 * 1024,
})
)

const response = await POST(createMockRequest('POST', baseBody))

expect(response.status).toBe(413)
const data = (await response.json()) as { error: string }
expect(data.error).toMatch(/exceeds the maximum supported size/i)

const call = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
expect(call[1]).toBe(PINNED_IP)
expect(call[2]).toMatchObject({ maxResponseBytes: 100 * 1024 * 1024 })
})

it('transcribes a normal, well-under-cap audio download successfully', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
mockSecureFetchResponse({})
)

const response = await POST(createMockRequest('POST', baseBody))

expect(response.status).toBe(200)
const data = (await response.json()) as { transcript: string }
expect(data.transcript).toBe('hello world')
})
})
10 changes: 8 additions & 2 deletions apps/sim/app/api/tools/stt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import {
downloadFileFromStorage,
resolveInternalFileUrl,
} from '@/lib/uploads/utils/file-utils.server'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { TranscriptSegment } from '@/tools/stt/types'

Expand Down Expand Up @@ -150,6 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, {
method: 'GET',
maxResponseBytes: MAX_FILE_SIZE,
})
if (!response.ok) {
await response.text().catch(() => {})
Expand Down Expand Up @@ -297,8 +300,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(response)
} catch (error) {
logger.error(`[${requestId}] STT proxy error:`, error)
const errorMessage = getErrorMessage(error, 'Unknown error')
return NextResponse.json({ error: errorMessage }, { status: 500 })
const isSizeLimit = isPayloadSizeLimitError(error)
const errorMessage = isSizeLimit
? 'Audio file exceeds the maximum supported size'
: getErrorMessage(error, 'Unknown error')
return NextResponse.json({ error: errorMessage }, { status: isSizeLimit ? 413 : 500 })
}
})

Expand Down
Loading