Skip to content

Commit 33d9afb

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): address BugBot findings
1 parent c332dda commit 33d9afb

7 files changed

Lines changed: 120 additions & 17 deletions

File tree

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,33 @@ describe('OAuth Token API Routes', () => {
579579
expect(data).toHaveProperty('error')
580580
})
581581

582+
it('rejects a malformed QuickBooks identity before reporting a missing token', async () => {
583+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
584+
ok: true,
585+
authType: 'session',
586+
requesterUserId: 'test-user-id',
587+
credentialOwnerUserId: 'test-user-id',
588+
})
589+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
590+
id: 'credential-id',
591+
accountId: 'malformed',
592+
accessToken: null,
593+
refreshToken: 'refresh-token',
594+
providerId: 'quickbooks',
595+
})
596+
597+
const response = await GET(
598+
new NextRequest(
599+
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
600+
) as any
601+
)
602+
const data = await response.json()
603+
604+
expect(response.status).toBe(401)
605+
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
606+
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
607+
})
608+
582609
it('should handle token refresh failure', async () => {
583610
mockAuthorizeCredentialUse.mockResolvedValueOnce({
584611
ok: true,

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,11 +371,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
371371
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
372372
}
373373

374-
if (!credential.accessToken) {
375-
logger.warn(`[${requestId}] No access token available for credential`)
376-
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
377-
}
378-
379374
const actorId = authz.requesterUserId
380375
const workspaceId = authz.workspaceId ?? null
381376

@@ -396,6 +391,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
396391
}
397392
}
398393

394+
if (!credential.accessToken) {
395+
logger.warn(`[${requestId}] No access token available for credential`)
396+
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
397+
}
398+
399399
try {
400400
const { accessToken } = await refreshTokenIfNeeded(
401401
requestId,

apps/sim/lib/quickbooks/fault.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,22 @@ export function sanitizeQuickBooksFaultData(data: unknown): SanitizedQuickBooksF
1111
const errors = (fault as Record<string, unknown>).Error
1212
if (!Array.isArray(errors)) return null
1313

14+
const sanitizedErrors = errors.flatMap((entry) => {
15+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
16+
const value = entry as Record<string, unknown>
17+
const sanitized = Object.fromEntries(
18+
['code', 'Message', 'Detail', 'element'].flatMap((key) => {
19+
const field = typeof value[key] === 'string' ? value[key].trim() : ''
20+
return field ? [[key, field]] : []
21+
})
22+
)
23+
return Object.keys(sanitized).length > 0 ? [sanitized] : []
24+
})
25+
if (sanitizedErrors.length === 0) return null
26+
1427
return {
1528
Fault: {
16-
Error: errors.flatMap((entry) => {
17-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
18-
const value = entry as Record<string, unknown>
19-
const sanitized = Object.fromEntries(
20-
['code', 'Message', 'Detail', 'element'].flatMap((key) =>
21-
typeof value[key] === 'string' ? [[key, value[key]]] : []
22-
)
23-
)
24-
return Object.keys(sanitized).length > 0 ? [sanitized] : []
25-
}),
29+
Error: sanitizedErrors,
2630
},
2731
}
2832
}

apps/sim/tools/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,42 @@ describe('OAuth provider context propagation', () => {
15171517
expect(fetchMock).toHaveBeenCalledTimes(2)
15181518
})
15191519

1520+
it('rejects a stale QuickBooks realm when the credential response omits its binding', async () => {
1521+
mockGenerateInternalToken.mockResolvedValue('internal-token')
1522+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
1523+
if (url.includes('/api/auth/oauth/token')) {
1524+
return new Response(JSON.stringify({ accessToken: 'fresh-access-token' }), {
1525+
headers: { 'Content-Type': 'application/json' },
1526+
})
1527+
}
1528+
1529+
throw new Error('QuickBooks API must not be called with an unbound realm')
1530+
})
1531+
global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch
1532+
1533+
const result = await executeTool(
1534+
'test_quickbooks_context',
1535+
{
1536+
credential: 'quickbooks-credential',
1537+
realmId: 'workflow-supplied-company',
1538+
},
1539+
{
1540+
executionContext: createToolExecutionContext({
1541+
userId: 'user-123',
1542+
workflowId: 'workflow-123',
1543+
}),
1544+
}
1545+
)
1546+
1547+
expect(result).toMatchObject({
1548+
success: false,
1549+
error: expect.stringContaining(
1550+
'QuickBooks company identity is missing. Reconnect the QuickBooks credential.'
1551+
),
1552+
})
1553+
expect(fetchMock).toHaveBeenCalledTimes(1)
1554+
})
1555+
15201556
it('does not expose a non-JSON QuickBooks failure body in tool output', async () => {
15211557
mockGenerateInternalToken.mockResolvedValue('internal-token')
15221558
const fetchMock = vi.fn().mockImplementation(async (url: string) => {

apps/sim/tools/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,8 +1248,15 @@ export async function executeTool(
12481248
if (data.domain && !contextParams.domain) {
12491249
contextParams.domain = data.domain
12501250
}
1251-
if (data.realmId) {
1252-
contextParams.realmId = data.realmId
1251+
if (tool?.oauth?.provider === 'quickbooks') {
1252+
const credentialRealmId = typeof data.realmId === 'string' ? data.realmId.trim() : ''
1253+
contextParams.realmId = undefined
1254+
if (!credentialRealmId) {
1255+
throw new Error(
1256+
'QuickBooks company identity is missing. Reconnect the QuickBooks credential.'
1257+
)
1258+
}
1259+
contextParams.realmId = credentialRealmId
12531260
}
12541261
if (data.authStyle && !contextParams.authStyle) {
12551262
contextParams.authStyle = data.authStyle

apps/sim/tools/quickbooks/error-extractor.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2+
import { sanitizeQuickBooksFaultData } from '@/lib/quickbooks/fault'
23
import { ErrorExtractorId, extractErrorMessage } from '@/tools/error-extractors'
34

45
describe('QuickBooks fault extraction', () => {
@@ -68,4 +69,15 @@ describe('QuickBooks fault extraction', () => {
6869
extractErrorMessage({ status: 502, data: null }, ErrorExtractorId.QUICKBOOKS_FAULT)
6970
).toBe('QuickBooks request failed with HTTP 502.')
7071
})
72+
73+
it('ignores fault envelopes without a usable error entry', () => {
74+
expect(sanitizeQuickBooksFaultData({ Fault: { Error: [] } })).toBeNull()
75+
expect(
76+
sanitizeQuickBooksFaultData({
77+
Fault: {
78+
Error: [{ code: ' ', Message: '', ignored: 'not a documented fault field' }, null],
79+
},
80+
})
81+
).toBeNull()
82+
})
7183
})

apps/sim/tools/quickbooks/quickbooks.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,23 @@ describe('QuickBooks response contracts', () => {
150150
})
151151
})
152152

153+
it('accepts a usable query response alongside an empty fault envelope', async () => {
154+
const result = await transformQuickBooksListResponse(
155+
Response.json({
156+
Fault: { Error: [] },
157+
QueryResponse: {
158+
Vendor: [{ Id: '7', DisplayName: 'Sanitized Vendor' }],
159+
startPosition: 1,
160+
maxResults: 1,
161+
},
162+
}),
163+
{ ...authParams, startPosition: 1, maxResults: 1 },
164+
'Vendor'
165+
)
166+
167+
expect(result.output.items).toEqual([{ Id: '7', DisplayName: 'Sanitized Vendor' }])
168+
})
169+
153170
it.each([
154171
['Vendor', { Id: '7', DisplayName: 'Sanitized Vendor' }],
155172
['PurchaseOrder', { Id: '8', DocNumber: 'PO-SANITIZED', TotalAmt: 42 }],

0 commit comments

Comments
 (0)