Skip to content

Commit 38075ad

Browse files
authored
fix(sap_concur): align the integration with SAP Concur's documented API (#6790)
* fix(sap_concur): align the integration with SAP Concur's documented API Validated all 70 tools, the block, and both proxy routes against SAP's published API docs. Auth: - add the password and companyUuid to the token cache key so a request with the wrong password can no longer be served a cached token minted from someone else's - wire the documented company-level flow (username = company UUID, credtype = authtoken) so companyUuid actually scopes a token - expand the datacenter allowlist to the documented set (adds glz, apj1, usg, the impl hosts, and the www- twins) and drop the undocumented cn host; validate the returned geolocation by shape instead of membership - coalesce concurrent token fetches so a fan-out mints one token - forward Retry-After so 429 retries pace off Concur's own hint - handle the errorMessageList, SCIM detail, and legacy Error.Message shapes instead of falling through to a generic HTTP message - pin redirects and cap the response body Block: - collapse six contextType subBlocks that disagreed on their default, so a new block no longer seeds MANAGER for every operation - clamp contextType to each operation's documented set - stop requiring a userId and contextType that the default operation's tool does not accept, and scope the receipt fields to the upload ops - reach six params that had no subBlock, and pass userId on travel request updates so a stale value cannot impersonate Tools: - correct response shapes that resolved to undefined: budget headers, budget categories, allocations, receipts, SCIM nextCursor, and the delete endpoints that return a bare boolean - use the Travel Request Amount schema (currency, not currencyCode) - narrow the four XML-only travel tools to a documented string payload and request application/xml - surface real errors instead of a JSON parse failure when the proxy returns a non-JSON body - cap receipt uploads at the documented sizes before downloading Adds 106 tests covering the token cache, geolocation validation, path traversal, and error extraction. * fix(sap_concur): drop the removed forwardId subblock via a migration Removing the `forwardId` subblock without a migration entry breaks deployed workflows that still carry a value under that key. It fed a `concur-forwardid` request header that is documented nowhere in Concur's Receipts v4 or Image v1 references, so it was never honored. There is no replacement subblock and the value is an opaque caller-chosen string rather than a secret, so it is dropped outright. * fix(sap_concur): stop swallowing upload response-read failures The upload route caught every error from the bounded response read and continued down the success path, so a size-limit breach or a stream failure surfaced as an upstream success with a null or header-only body. Concur returns Content-Length: 0 on a successful image-only upload, and readResponseTextWithLimit already returns an empty string for that without throwing, so dropping the catch keeps the legitimate empty-body case working while letting real read failures reach the route's handler. * fix(sap_concur): unblock company auth and correct the body wand prompt The password grant marked username required, so the company-level flow — which sends the company UUID as the token username and has no user login — could not be configured at all, even though the request schema and token fetch already accept companyUuid without a username. Username is now optional for that grant and the server-side check reports which of the two is missing. Relabels the password and companyUuid fields to say what they carry in the company flow. The shared body wand prompt also still described several payloads the way they looked before this branch: quick expenses in PascalCase rather than v4 camelCase, travel requests and expected expenses using currencyCode where the Request v4 Amount schema uses currency, the standard SCIM SearchRequest URN instead of Concur's, startIndex as a search parameter when it is unsupported, and a cash advance shape that does not match the documented request. A wand-generated body was therefore rejected for most of the create operations it covers. * fix(sap_concur): keep Concur's status when an error body fails to read Removing the blanket catch from the upload read fixed one failure mode and introduced its inverse: a cap breach or stream error while reading a non-success body threw before the route reached the branch that preserves Concur's status, so an upstream 4xx surfaced as a Sim 500 and could trigger a retry the caller should not make. Both routes now split the two cases. On a success status the body is the result, so a read failure still propagates. On an error status the body only supplies the message, so a read failure resolves empty and the upstream status survives, with the message falling back to the generic HTTP-status form. Adds 21 tests covering both helpers over success, error, empty-body and boundary statuses; inverting the status check turns 14 of them red.
1 parent 60097c8 commit 38075ad

73 files changed

Lines changed: 3724 additions & 1240 deletions

File tree

Some content is hidden

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

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

Lines changed: 302 additions & 245 deletions
Large diffs are not rendered by default.

apps/sim/app/api/tools/sap_concur/proxy/route.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { createLogger } from '@sim/logger'
2-
import { toError } from '@sim/utils/errors'
2+
import { generateId } from '@sim/utils/id'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { getValidationErrorMessage, isZodError } from '@/lib/api/server'
55
import { checkInternalAuth } from '@/lib/auth/hybrid'
6-
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
6+
import {
7+
MAX_JSON_API_RESPONSE_BYTES,
8+
secureFetchWithValidation,
9+
} from '@/lib/core/security/input-validation.server'
710
import { generateRequestId } from '@/lib/core/utils/request'
811
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
912
import {
1013
assertSafeExternalUrl,
14+
describeSapConcurFetchError,
1115
extractSapConcurError,
1216
fetchSapConcurAccessToken,
17+
forwardedSapConcurHeaders,
1318
SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS,
1419
type SapConcurProxyRequest,
1520
SapConcurProxyRequestSchema,
@@ -39,10 +44,52 @@ function buildApiUrl(geolocation: string, req: ProxyRequest): string {
3944
return url.includes('?') ? `${url}&${queryString}` : `${url}?${queryString}`
4045
}
4146

47+
/**
48+
* Map a non-2xx Concur status that cannot be re-emitted as an error status onto 502.
49+
*
50+
* With `maxRedirects: 0` a 3xx carrying a `Location` never reaches here — it rejects with
51+
* "Too many redirects" and is handled in the outer catch. What does reach here is a 3xx
52+
* *without* a `Location`, and a 304, which is excluded from the redirect handling
53+
* upstream. Neither is a usable error status to return to the caller.
54+
*/
55+
function clampErrorStatus(status: number): number {
56+
return status >= 400 ? status : 502
57+
}
58+
4259
interface Invocation {
4360
status: number
4461
body: unknown
4562
raw: string
63+
/** Concur response headers forwarded onto this route's response. */
64+
headers: Record<string, string>
65+
}
66+
67+
/**
68+
* Invoke a Concur API endpoint with the bearer token.
69+
*
70+
* `concur-correlationid` is a support/tracing header expected to be a fresh RFC 4122
71+
* UUID per request; it does not scope a request to a company. Redirects are refused so
72+
* the Authorization header is never forwarded to another origin.
73+
*
74+
* `stripAuthOnRedirect` is unreachable while `maxRedirects` is 0 — no redirect is ever
75+
* followed for it to act on. It is kept as defense-in-depth so raising `maxRedirects`
76+
* later cannot silently start forwarding the bearer token; do not remove it as dead code.
77+
*/
78+
/**
79+
* Read a Concur response body, keeping the upstream status meaningful.
80+
*
81+
* On a success status the body is the result, so a stream failure is a real
82+
* error and must propagate. On an error status the body only supplies the
83+
* message, and throwing would turn Concur's 4xx into a Sim 500 — the status is
84+
* preserved instead and the message falls back to the generic HTTP-status form.
85+
*/
86+
export async function readConcurProxyBody(response: {
87+
status: number
88+
text: () => Promise<string>
89+
}): Promise<string> {
90+
const read = response.text()
91+
if (response.status >= 200 && response.status < 300) return read
92+
return read.catch(() => '')
4693
}
4794

4895
async function callConcur(
@@ -54,10 +101,10 @@ async function callConcur(
54101
const hasBody = req.body !== undefined && req.body !== null
55102
const headers: Record<string, string> = {
56103
Authorization: `Bearer ${accessToken}`,
57-
Accept: 'application/json',
104+
Accept: req.accept ?? 'application/json',
58105
}
59106
if (hasBody) headers['Content-Type'] = req.contentType ?? 'application/json'
60-
if (req.companyUuid) headers['concur-correlationid'] = req.companyUuid
107+
headers['concur-correlationid'] = generateId()
61108

62109
const response = await secureFetchWithValidation(
63110
url,
@@ -70,11 +117,14 @@ async function callConcur(
70117
: JSON.stringify(req.body)
71118
: undefined,
72119
timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS,
120+
maxRedirects: 0,
121+
stripAuthOnRedirect: true,
122+
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
73123
},
74124
'apiUrl'
75125
)
76126

77-
const raw = await response.text()
127+
const raw = await readConcurProxyBody(response)
78128
let parsed: unknown = null
79129
if (raw.length > 0) {
80130
try {
@@ -83,7 +133,12 @@ async function callConcur(
83133
parsed = raw
84134
}
85135
}
86-
return { status: response.status, body: parsed, raw }
136+
return {
137+
status: response.status,
138+
body: parsed,
139+
raw,
140+
headers: forwardedSapConcurHeaders(response.headers),
141+
}
87142
}
88143

89144
export const POST = withRouteHandler(async (request: NextRequest) => {
@@ -108,7 +163,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
108163

109164
if (invocation.status >= 200 && invocation.status < 300) {
110165
const data = invocation.status === 204 ? null : invocation.body
111-
return NextResponse.json({ success: true, output: { status: invocation.status, data } })
166+
return NextResponse.json(
167+
{ success: true, output: { status: invocation.status, data } },
168+
{ headers: invocation.headers }
169+
)
112170
}
113171

114172
const message = extractSapConcurError(invocation.body, invocation.status)
@@ -117,7 +175,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
117175
)
118176
return NextResponse.json(
119177
{ success: false, error: message, status: invocation.status },
120-
{ status: invocation.status }
178+
{ status: clampErrorStatus(invocation.status), headers: invocation.headers }
121179
)
122180
} catch (error) {
123181
if (isZodError(error)) {
@@ -128,6 +186,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
128186
)
129187
}
130188
logger.error(`[${requestId}] Unexpected Concur proxy error:`, error)
131-
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
189+
return NextResponse.json(
190+
{ success: false, error: describeSapConcurFetchError(error) },
191+
{ status: 500 }
192+
)
132193
}
133194
})
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockReadResponseTextWithLimit, mockSecureFetch, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({
7+
mockReadResponseTextWithLimit: vi.fn(),
8+
mockSecureFetch: vi.fn(),
9+
MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024,
10+
}))
11+
12+
vi.mock('@/lib/core/utils/stream-limits', () => {
13+
class PayloadSizeLimitError extends Error {
14+
observedBytes?: number
15+
constructor(message: string, observedBytes?: number) {
16+
super(message)
17+
this.name = 'PayloadSizeLimitError'
18+
this.observedBytes = observedBytes
19+
}
20+
}
21+
return {
22+
PayloadSizeLimitError,
23+
readResponseTextWithLimit: mockReadResponseTextWithLimit,
24+
}
25+
})
26+
27+
vi.mock('@/lib/core/security/input-validation.server', () => ({
28+
secureFetchWithValidation: mockSecureFetch,
29+
MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES,
30+
}))
31+
32+
import { readConcurProxyBody } from '@/app/api/tools/sap_concur/proxy/route'
33+
import { readConcurUploadBody } from '@/app/api/tools/sap_concur/upload/route'
34+
35+
/** Minimal response shape both helpers accept. */
36+
function uploadResponse(status: number): Parameters<typeof readConcurUploadBody>[0] {
37+
return {
38+
status,
39+
headers: new Headers(),
40+
body: null,
41+
}
42+
}
43+
44+
function proxyResponse(
45+
status: number,
46+
text: () => Promise<string>
47+
): Parameters<typeof readConcurProxyBody>[0] {
48+
return { status, text }
49+
}
50+
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
mockReadResponseTextWithLimit.mockReset()
54+
})
55+
56+
/**
57+
* Both helpers make the same success/error split, so the cases are declared once and run
58+
* against each helper. `readConcurUploadBody` reads through the mocked
59+
* `readResponseTextWithLimit`; `readConcurProxyBody` reads through `response.text()`.
60+
*/
61+
const helpers = [
62+
{
63+
name: 'readConcurUploadBody',
64+
read: (status: number, result: Promise<string>) => {
65+
mockReadResponseTextWithLimit.mockReturnValue(result)
66+
return readConcurUploadBody(uploadResponse(status))
67+
},
68+
},
69+
{
70+
name: 'readConcurProxyBody',
71+
read: (status: number, result: Promise<string>) =>
72+
readConcurProxyBody(proxyResponse(status, () => result)),
73+
},
74+
] as const
75+
76+
describe.each(helpers)('$name response body reads', ({ read }) => {
77+
it('resolves with the body text on a success status', async () => {
78+
await expect(read(200, Promise.resolve('{"id":"exp-1"}'))).resolves.toBe('{"id":"exp-1"}')
79+
})
80+
81+
it('resolves with an empty string for an empty success body', async () => {
82+
await expect(read(200, Promise.resolve(''))).resolves.toBe('')
83+
})
84+
85+
it('propagates a read failure on a success status', async () => {
86+
const failure = new Error('Concur upload response exceeded 10485760 bytes')
87+
await expect(read(201, Promise.reject(failure))).rejects.toBe(failure)
88+
})
89+
90+
it('resolves with the body text on an error status', async () => {
91+
await expect(read(400, Promise.resolve('{"message":"Invalid userId"}'))).resolves.toBe(
92+
'{"message":"Invalid userId"}'
93+
)
94+
})
95+
96+
it('swallows a read failure on a 4xx status', async () => {
97+
await expect(read(403, Promise.reject(new Error('stream aborted')))).resolves.toBe('')
98+
})
99+
100+
it('swallows a read failure on a 5xx status', async () => {
101+
await expect(read(503, Promise.reject(new Error('stream aborted')))).resolves.toBe('')
102+
})
103+
104+
/**
105+
* The source compares `status >= 200 && status < 300`, so 200 and 299 take the strict
106+
* path and 199 and 300 take the tolerant one.
107+
*/
108+
it.each([200, 299])('treats %i as a success status', async (status) => {
109+
const failure = new Error('read failed')
110+
await expect(read(status, Promise.reject(failure))).rejects.toBe(failure)
111+
})
112+
113+
it.each([199, 300])('treats %i as a non-success status', async (status) => {
114+
await expect(read(status, Promise.reject(new Error('read failed')))).resolves.toBe('')
115+
})
116+
})
117+
118+
describe('readConcurUploadBody byte cap wiring', () => {
119+
it('reads under the shared JSON response byte cap', async () => {
120+
mockReadResponseTextWithLimit.mockReturnValue(Promise.resolve('{}'))
121+
const response = uploadResponse(200)
122+
123+
await expect(readConcurUploadBody(response)).resolves.toBe('{}')
124+
125+
expect(mockReadResponseTextWithLimit).toHaveBeenCalledWith(response, {
126+
maxBytes: MOCK_MAX_JSON_BYTES,
127+
label: 'Concur upload response',
128+
})
129+
})
130+
})

0 commit comments

Comments
 (0)