Skip to content

Commit 88935c0

Browse files
committed
fix(agiloft): verify the record a projected read returns, and fail an ID-less create
Two findings from review. A projected read interpolates the record ID into a search predicate. A value carrying query operators could change which records the predicate selects, and the route then accepted the first row without checking it. Agiloft record IDs are integers, so a non-numeric ID is now rejected outright rather than escaped, and the row is matched on ID instead of taken positionally. A create that returned no ID still reported success with a null ID. Callers chain on that ID, so it now fails instead.
1 parent c83f90c commit 88935c0

3 files changed

Lines changed: 78 additions & 7 deletions

File tree

apps/sim/app/api/tools/agiloft/create_record/route.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,42 @@ describe('search result ceiling', () => {
230230
expect(data.output.totalCount).toBe(200)
231231
})
232232
})
233+
234+
describe('review round 1 fixes', () => {
235+
it('fails a create that comes back without a record ID', async () => {
236+
arrange(res({ json: { success: true, result: {} } }))
237+
238+
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
239+
const data = (await response.json()) as { success: boolean; error?: string }
240+
241+
expect(data.success).toBe(false)
242+
expect(data.error).toContain('did not return an ID')
243+
})
244+
245+
it('refuses a non-numeric record ID on a projected read rather than interpolating it', async () => {
246+
const response = await READ(
247+
createMockRequest('POST', {
248+
...baseBody,
249+
recordId: "1' || priority='High",
250+
fields: 'contract_title1',
251+
})
252+
)
253+
const data = (await response.json()) as { success: boolean; error?: string }
254+
255+
expect(data.success).toBe(false)
256+
expect(data.error).toContain('must be numeric')
257+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
258+
})
259+
260+
it('does not return an unrelated record when the search matches something else', async () => {
261+
arrange(res({ json: { success: true, result: [{ id: 99, contract_title1: 'Other' }] } }))
262+
263+
const response = await READ(
264+
createMockRequest('POST', { ...baseBody, recordId: '6342', fields: 'contract_title1' })
265+
)
266+
const data = (await response.json()) as { success: boolean; error?: string }
267+
268+
expect(data.success).toBe(false)
269+
expect(data.error).toContain('no record for ID 6342')
270+
})
271+
})

apps/sim/app/api/tools/agiloft/create_record/route.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7676
const record = await readAlrestJson<Record<string, unknown>>(response)
7777
const id = record?.id
7878

79+
/**
80+
* A create that reports no ID did not create anything usable — callers
81+
* chain on this ID, so surface it as a failure rather than handing back
82+
* a successful-looking null.
83+
*/
84+
if (id == null) {
85+
return {
86+
success: false,
87+
output: { id: null, fields: record ?? {} },
88+
error: 'Agiloft did not return an ID for the created record',
89+
}
90+
}
91+
7992
return {
8093
success: true,
81-
output: {
82-
id: id == null ? null : String(id),
83-
fields: record ?? {},
84-
},
94+
output: { id: String(id), fields: record ?? {} },
8595
}
8696
}
8797
)

apps/sim/app/api/tools/agiloft/read_record/route.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6161
* fetch is cheaper.
6262
*/
6363
const requestedFields = parseFieldList(params.fields)
64+
const recordId = params.recordId.trim()
65+
66+
/**
67+
* The projected read puts the ID inside a search predicate, so anything
68+
* other than a plain number could change which records the query selects.
69+
* Agiloft record IDs are integers, so reject everything else rather than
70+
* trying to escape it.
71+
*/
72+
if (requestedFields && !/^\d+$/.test(recordId)) {
73+
return NextResponse.json({
74+
success: false,
75+
output: { id: null, fields: {} },
76+
error: `Record ID must be numeric to read specific fields, got "${recordId}"`,
77+
})
78+
}
6479

6580
const result = await executeAlrestRequest<AgiloftRecordResponse>(
6681
params,
@@ -79,7 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7994
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
8095
body: JSON.stringify({
8196
field: requestedFields.includes('id') ? requestedFields : ['id', ...requestedFields],
82-
query: `id=${params.recordId.trim()}`,
97+
query: `id=${recordId}`,
8398
}),
8499
}
85100
},
@@ -88,13 +103,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
88103
response
89104
)
90105

91-
const record = Array.isArray(payload) ? payload[0] : payload
106+
/**
107+
* Match on ID rather than taking the first row: a search answers with a
108+
* result set, and returning an unrelated record as a successful read
109+
* would be worse than failing.
110+
*/
111+
const record = Array.isArray(payload)
112+
? payload.find((row) => String(row?.id ?? '') === recordId)
113+
: payload
92114

93115
if (!record) {
94116
return {
95117
success: false,
96118
output: { id: null, fields: {} },
97-
error: `Agiloft returned no record for ID ${params.recordId.trim()}`,
119+
error: `Agiloft returned no record for ID ${recordId}`,
98120
}
99121
}
100122

0 commit comments

Comments
 (0)