Skip to content

Commit 5ab6d0d

Browse files
authored
feat(integrations): add Brex integration (#4983)
* feat(integrations): add Brex integration with expenses, receipts, transactions, team, budgets, and payments tools * fix(brex): reject whitespace-only expense IDs in receipt upload instead of silently falling back to receipt match * fix(brex): trim receipt name in contract so whitespace-only overrides are rejected * fix(brex): align spend limit balance shape, enum descriptions, and pagination metadata with Brex API specs * improvement(brex): validate pre-signed upload URL with DNS pinning and harden API key input * fix(brex): correct shared limit placeholder to reflect the 100-item cap on list expenses * fix(brex): normalize timezone-suffixed timestamps for transactions date filters (Brex rejects offsets)
1 parent f7b40fe commit 5ab6d0d

46 files changed

Lines changed: 5741 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2261,6 +2261,17 @@ export function BrandfetchIcon(props: SVGProps<SVGSVGElement>) {
22612261
)
22622262
}
22632263

2264+
export function BrexIcon(props: SVGProps<SVGSVGElement>) {
2265+
return (
2266+
<svg {...props} viewBox='0 0 223 179.3' fill='none' xmlns='http://www.w3.org/2000/svg'>
2267+
<path
2268+
fill='#FFFFFF'
2269+
d='M144.9,14.3c-8.7,11.6-10.8,15.5-19.2,15.5H0v149.4h49.3c11.1,0,21.9-5.4,28.9-14.3c9-12,10.2-15.5,18.9-15.5 H223V0h-49.6C162.3,0,151.5,5.4,144.9,14.3L144.9,14.3z M183.9,110.9h-52.6c-11.4,0-21.9,4.8-28.9,14c-9,12-10.8,15.5-19.2,15.5 H38.8V68.7h52.6c11.4,0,21.9-5.4,28.9-14.3c9-11.6,11.4-15.2,19.5-15.2h44.2V110.9z'
2270+
/>
2271+
</svg>
2272+
)
2273+
}
2274+
22642275
export function BrightDataIcon(props: SVGProps<SVGSVGElement>) {
22652276
return (
22662277
<svg

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
BoxCompanyIcon,
2525
BrainIcon,
2626
BrandfetchIcon,
27+
BrexIcon,
2728
BrightDataIcon,
2829
BrowserUseIcon,
2930
CalComIcon,
@@ -243,6 +244,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
243244
azure_devops: AzureIcon,
244245
box: BoxCompanyIcon,
245246
brandfetch: BrandfetchIcon,
247+
brex: BrexIcon,
246248
brightdata: BrightDataIcon,
247249
browser_use: BrowserUseIcon,
248250
calcom: CalComIcon,

apps/docs/content/docs/en/integrations/brex.mdx

Lines changed: 895 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"azure_devops",
2222
"box",
2323
"brandfetch",
24+
"brex",
2425
"brightdata",
2526
"browser_use",
2627
"calcom",
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
hybridAuthMockFns,
7+
inputValidationMock,
8+
inputValidationMockFns,
9+
} from '@sim/testing'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
12+
const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } =
13+
vi.hoisted(() => ({
14+
mockProcessFilesToUserFiles: vi.fn(),
15+
mockDownloadFileFromStorage: vi.fn(),
16+
mockAssertToolFileAccess: vi.fn(),
17+
}))
18+
19+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
20+
vi.mock('@/lib/uploads/utils/file-utils', () => ({
21+
processFilesToUserFiles: mockProcessFilesToUserFiles,
22+
}))
23+
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
24+
downloadFileFromStorage: mockDownloadFileFromStorage,
25+
}))
26+
vi.mock('@/app/api/files/authorization', () => ({
27+
assertToolFileAccess: mockAssertToolFileAccess,
28+
}))
29+
30+
import { POST } from '@/app/api/tools/brex/upload-receipt/route'
31+
32+
const mockFetch = vi.fn()
33+
34+
const PINNED_IP = '52.216.0.1'
35+
36+
const baseBody = {
37+
apiKey: 'bxt_test_token',
38+
expenseId: 'expense_123',
39+
file: { key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
40+
}
41+
42+
function jsonResponse(body: unknown, status = 200) {
43+
return {
44+
ok: status >= 200 && status < 300,
45+
status,
46+
text: async () => JSON.stringify(body),
47+
json: async () => body,
48+
}
49+
}
50+
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
vi.stubGlobal('fetch', mockFetch)
54+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
55+
success: true,
56+
userId: 'user-1',
57+
authType: 'internal_jwt',
58+
})
59+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
60+
isValid: true,
61+
resolvedIP: PINNED_IP,
62+
})
63+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(jsonResponse({}))
64+
mockProcessFilesToUserFiles.mockReturnValue([
65+
{ key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
66+
])
67+
mockAssertToolFileAccess.mockResolvedValue(null)
68+
mockDownloadFileFromStorage.mockResolvedValue(Buffer.from('receipt-bytes'))
69+
})
70+
71+
describe('POST /api/tools/brex/upload-receipt', () => {
72+
it('rejects unauthenticated requests', async () => {
73+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
74+
success: false,
75+
error: 'unauthorized',
76+
})
77+
78+
const response = await POST(createMockRequest('POST', baseBody))
79+
expect(response.status).toBe(401)
80+
expect(mockFetch).not.toHaveBeenCalled()
81+
})
82+
83+
it('creates a receipt upload for an expense and PUTs the file to the pre-signed URL', async () => {
84+
mockFetch.mockResolvedValueOnce(
85+
jsonResponse({ id: 'receipt_1', uri: 'https://s3.example.com/presigned' })
86+
)
87+
88+
const response = await POST(createMockRequest('POST', baseBody))
89+
expect(response.status).toBe(200)
90+
const data = await response.json()
91+
expect(data).toEqual({
92+
success: true,
93+
output: { receiptId: 'receipt_1', receiptName: 'receipt.pdf', expenseId: 'expense_123' },
94+
})
95+
96+
expect(mockFetch).toHaveBeenCalledTimes(1)
97+
const [createUrl, createInit] = mockFetch.mock.calls[0]
98+
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/expense_123/receipt_upload')
99+
expect(createInit.method).toBe('POST')
100+
expect(createInit.headers.Authorization).toBe('Bearer bxt_test_token')
101+
expect(JSON.parse(createInit.body)).toEqual({ receipt_name: 'receipt.pdf' })
102+
103+
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledWith(
104+
'https://s3.example.com/presigned',
105+
'uri'
106+
)
107+
const [uploadUrl, pinnedIP, uploadInit] =
108+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
109+
expect(uploadUrl).toBe('https://s3.example.com/presigned')
110+
expect(pinnedIP).toBe(PINNED_IP)
111+
expect(uploadInit.method).toBe('PUT')
112+
})
113+
114+
it('rejects a whitespace-only expense ID instead of falling back to receipt match', async () => {
115+
const response = await POST(createMockRequest('POST', { ...baseBody, expenseId: ' ' }))
116+
expect(response.status).toBe(400)
117+
expect(mockFetch).not.toHaveBeenCalled()
118+
})
119+
120+
it('trims a padded expense ID before building the upload URL', async () => {
121+
mockFetch.mockResolvedValueOnce(
122+
jsonResponse({ id: 'receipt_5', uri: 'https://s3.example.com/presigned' })
123+
)
124+
125+
const response = await POST(
126+
createMockRequest('POST', { ...baseBody, expenseId: ' expense_123 ' })
127+
)
128+
expect(response.status).toBe(200)
129+
const [createUrl] = mockFetch.mock.calls[0]
130+
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/expense_123/receipt_upload')
131+
const data = await response.json()
132+
expect(data.output.expenseId).toBe('expense_123')
133+
})
134+
135+
it('rejects a whitespace-only receipt name', async () => {
136+
const response = await POST(createMockRequest('POST', { ...baseBody, receiptName: ' ' }))
137+
expect(response.status).toBe(400)
138+
expect(mockFetch).not.toHaveBeenCalled()
139+
})
140+
141+
it('rejects an API key containing header-breaking characters', async () => {
142+
const response = await POST(
143+
createMockRequest('POST', { ...baseBody, apiKey: 'bxt_test\r\nX-Injected: 1' })
144+
)
145+
expect(response.status).toBe(400)
146+
expect(mockFetch).not.toHaveBeenCalled()
147+
})
148+
149+
it('uses receipt match when no expense ID is provided', async () => {
150+
mockFetch.mockResolvedValueOnce(
151+
jsonResponse({ id: 'receipt_2', uri: 'https://s3.example.com/presigned' })
152+
)
153+
154+
const response = await POST(
155+
createMockRequest('POST', { apiKey: 'bxt_test_token', file: baseBody.file })
156+
)
157+
expect(response.status).toBe(200)
158+
const data = await response.json()
159+
expect(data.output).toEqual({
160+
receiptId: 'receipt_2',
161+
receiptName: 'receipt.pdf',
162+
expenseId: null,
163+
})
164+
165+
const [createUrl] = mockFetch.mock.calls[0]
166+
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/receipt_match')
167+
})
168+
169+
it('honors a receipt name override', async () => {
170+
mockFetch.mockResolvedValueOnce(
171+
jsonResponse({ id: 'receipt_3', uri: 'https://s3.example.com/presigned' })
172+
)
173+
174+
const response = await POST(
175+
createMockRequest('POST', { ...baseBody, receiptName: 'march-dinner.pdf' })
176+
)
177+
expect(response.status).toBe(200)
178+
const [, createInit] = mockFetch.mock.calls[0]
179+
expect(JSON.parse(createInit.body)).toEqual({ receipt_name: 'march-dinner.pdf' })
180+
})
181+
182+
it('propagates Brex API errors', async () => {
183+
mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Expense not found' }, 404))
184+
185+
const response = await POST(createMockRequest('POST', baseBody))
186+
expect(response.status).toBe(404)
187+
const data = await response.json()
188+
expect(data.success).toBe(false)
189+
expect(data.error).toContain('Expense not found')
190+
expect(mockFetch).toHaveBeenCalledTimes(1)
191+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
192+
})
193+
194+
it('rejects files over the 50 MB limit', async () => {
195+
mockDownloadFileFromStorage.mockResolvedValueOnce(Buffer.alloc(50 * 1024 * 1024 + 1))
196+
197+
const response = await POST(createMockRequest('POST', baseBody))
198+
expect(response.status).toBe(400)
199+
const data = await response.json()
200+
expect(data.error).toContain('50 MB')
201+
expect(mockFetch).not.toHaveBeenCalled()
202+
})
203+
204+
it('blocks pre-signed URLs that fail SSRF validation', async () => {
205+
mockFetch.mockResolvedValueOnce(
206+
jsonResponse({ id: 'receipt_6', uri: 'https://169.254.169.254/latest/meta-data' })
207+
)
208+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({
209+
isValid: false,
210+
error: 'uri resolves to a blocked IP address',
211+
})
212+
213+
const response = await POST(createMockRequest('POST', baseBody))
214+
expect(response.status).toBe(502)
215+
const data = await response.json()
216+
expect(data.error).toContain('invalid upload URL')
217+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
218+
})
219+
220+
it('fails when the pre-signed upload fails', async () => {
221+
mockFetch.mockResolvedValueOnce(
222+
jsonResponse({ id: 'receipt_4', uri: 'https://s3.example.com/presigned' })
223+
)
224+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(jsonResponse({}, 403))
225+
226+
const response = await POST(createMockRequest('POST', baseBody))
227+
expect(response.status).toBe(502)
228+
const data = await response.json()
229+
expect(data.success).toBe(false)
230+
})
231+
232+
it('denies access to files the caller cannot read', async () => {
233+
const deniedResponse = new Response(
234+
JSON.stringify({ success: false, error: 'File not found' }),
235+
{
236+
status: 404,
237+
}
238+
)
239+
mockAssertToolFileAccess.mockResolvedValueOnce(deniedResponse)
240+
241+
const response = await POST(createMockRequest('POST', baseBody))
242+
expect(response.status).toBe(404)
243+
expect(mockFetch).not.toHaveBeenCalled()
244+
})
245+
})

0 commit comments

Comments
 (0)