Skip to content

Commit 307492d

Browse files
committed
fix(agiloft): repoint the block at the alrest surface and fix EWLogin
The native block could not authenticate against any instance. A customer reported it, and production traces for their workspace confirm every failure mode verbatim. EWLogin was sending only $KB, $login, and $password as query parameters. A live instance rejects that: EWWrongDataException has occurred: One has to specify $table, $KB, $lang parameters or use $genhotlink/$genproject pair ... $table is required even though only $KB/$login/$password/$lang are documented. Credentials now go in a form-encoded body, which Agiloft's docs allow and which keeps the password out of URLs and access logs. Because login failed, no data call was ever reached — which hid the fact that the record operations were pointed at a surface that cannot use the token EWLogin issues. /ewws/EW* authenticates from inline $login/$password and rejects a bearer token; /ewws/alrest/{KB} is the surface that accepts it. - Record create, read, update, delete, search, and attachment retrieval now go through /ewws/alrest/{KB} with the bearer token, using the authentication scheme returned by the login response (Agiloft sends it with a trailing space, so it is trimmed rather than concatenated blind) - alrest reports failures as HTTP 200 with {"success": false, errors: [...]}, so readAlrestJson is the single sanctioned reader; checking response.ok alone turned refusals into successful empty results - Operations with no documented alrest equivalent stay on /ewws/EW* but now authenticate inline instead of sending a token that surface rejects - Optional string inputs accept null. A blank Page field resolved to null and failed validation with "expected string, received null" before any request - Reads with a named field list go through the search projection; an unfiltered contract record is roughly 184KB and swamps downstream agent context
1 parent 6d3e484 commit 307492d

25 files changed

Lines changed: 639 additions & 708 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function mockSecureFetchResponse(body: {
5555
statusText: '',
5656
headers: new Headers(),
5757
body: null,
58-
text: async () => body.text ?? '',
58+
text: async () => body.text ?? JSON.stringify(body.json ?? {}),
5959
json: async () => body.json ?? {},
6060
arrayBuffer: async () => new ArrayBuffer(0),
6161
}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9999
return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 })
100100
}
101101

102-
const token = await agiloftLoginPinned(data, resolvedIP)
102+
const session = await agiloftLoginPinned(data, resolvedIP)
103103
const base = data.instanceUrl.replace(/\/$/, '')
104104

105105
try {
@@ -111,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
111111
method: 'PUT',
112112
headers: {
113113
'Content-Type': 'application/octet-stream',
114-
Authorization: `Bearer ${token}`,
114+
Authorization: session.authorization,
115115
},
116116
body: new Uint8Array(fileBuffer),
117117
})
@@ -151,7 +151,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
151151
},
152152
})
153153
} finally {
154-
await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
154+
await agiloftLogoutPinned(
155+
data.instanceUrl,
156+
data.knowledgeBase,
157+
session.authorization,
158+
resolvedIP
159+
)
155160
}
156161
} catch (error) {
157162
logger.error(`[${requestId}] Error attaching file to Agiloft:`, error)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import type { AgiloftAttachmentInfoResponse } from '@/tools/agiloft/types'
1010
import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils'
11-
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
11+
import { executeEwRequest } from '@/tools/agiloft/utils.server'
1212

1313
export const dynamic = 'force-dynamic'
1414

@@ -51,7 +51,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5151
if (!parsed.success) return parsed.response
5252
const params = parsed.data.body
5353

54-
const result = await executeAgiloftRequest<AgiloftAttachmentInfoResponse>(
54+
const result = await executeEwRequest<AgiloftAttachmentInfoResponse>(
5555
params,
5656
(base) => ({
5757
url: buildAttachmentInfoUrl(base, params),

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

Lines changed: 123 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,38 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1212
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
1313

1414
import { POST } from '@/app/api/tools/agiloft/create_record/route'
15+
import { POST as READ } from '@/app/api/tools/agiloft/read_record/route'
1516
import { POST as SEARCH } from '@/app/api/tools/agiloft/search_records/route'
16-
import { POST as SELECT } from '@/app/api/tools/agiloft/select_records/route'
1717

1818
const PINNED_IP = '93.184.216.34'
1919

2020
const baseBody = {
2121
instanceUrl: 'https://example.agiloft.com',
22-
knowledgeBase: 'Demo',
23-
login: 'admin',
24-
password: 'secret',
25-
table: 'contacts.employees',
26-
data: JSON.stringify({ first_name: 'John', last_name: 'Doe' }),
22+
knowledgeBase: 'Russell Investments',
23+
login: 'svc.user',
24+
password: 's3cr3t',
25+
table: 'contract',
2726
}
2827

29-
function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
28+
function res(body: { ok?: boolean; status?: number; json?: unknown; text?: string }) {
29+
const text = body.text ?? JSON.stringify(body.json ?? {})
3030
return {
3131
ok: body.ok ?? true,
32-
status: body.ok === false ? 400 : 200,
32+
status: body.status ?? 200,
3333
statusText: '',
3434
headers: new Headers(),
3535
body: null,
36-
text: async () => body.text ?? '',
37-
json: async () => body.json ?? {},
36+
text: async () => text,
37+
json: async () => JSON.parse(text),
3838
arrayBuffer: async () => new ArrayBuffer(0),
3939
}
4040
}
4141

42+
/** Login envelope Agiloft returns — note the trailing space on the scheme. */
43+
const LOGIN_OK = res({
44+
json: { access_token: 'tok-123', authentication_scheme: 'Bearer ' },
45+
})
46+
4247
beforeEach(() => {
4348
vi.clearAllMocks()
4449
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
@@ -53,124 +58,149 @@ beforeEach(() => {
5358
})
5459
})
5560

56-
describe('POST /api/tools/agiloft/create_record', () => {
57-
it("reads the record ID out of EWCreate's EWREST_id assignment", async () => {
58-
inputValidationMockFns.mockSecureFetchWithPinnedIP
59-
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
60-
.mockResolvedValueOnce(mockSecureFetchResponse({ text: "EWREST_id='353';" }))
61-
.mockResolvedValueOnce(mockSecureFetchResponse({}))
62-
63-
const response = await POST(createMockRequest('POST', baseBody))
64-
const data = (await response.json()) as {
65-
success: boolean
66-
output: { id: string | null }
67-
}
68-
69-
expect(data.success).toBe(true)
70-
expect(data.output.id).toBe('353')
61+
function arrange(operationResponse: ReturnType<typeof res>) {
62+
inputValidationMockFns.mockSecureFetchWithPinnedIP
63+
.mockResolvedValueOnce(LOGIN_OK)
64+
.mockResolvedValueOnce(operationResponse)
65+
.mockResolvedValueOnce(res({}))
66+
}
7167

72-
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
73-
expect(operationCall[0]).toContain('/ewws/EWCreate?')
74-
expect(operationCall[0]).toContain('&first_name=John')
75-
expect(operationCall[2]).toMatchObject({ method: 'POST' })
68+
describe('EWLogin', () => {
69+
it('sends $table and $lang alongside $KB in a form body, which the live server requires', async () => {
70+
arrange(res({ json: { success: true, result: { id: 6342 } } }))
71+
72+
await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
73+
74+
const [url, ip, init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
75+
expect(url).toBe('https://example.agiloft.com/ewws/EWLogin')
76+
expect(ip).toBe(PINNED_IP)
77+
expect(init.method).toBe('POST')
78+
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
79+
80+
const sent = new URLSearchParams(init.body as string)
81+
expect(sent.get('$KB')).toBe('Russell Investments')
82+
expect(sent.get('$table')).toBe('contract')
83+
expect(sent.get('$lang')).toBe('en')
84+
expect(sent.get('$login')).toBe('svc.user')
85+
expect(sent.get('$password')).toBe('s3cr3t')
86+
// Credentials must not leak into the URL.
87+
expect(url).not.toContain('s3cr3t')
7688
})
7789

78-
it('fails loudly when Agiloft answers 200 with something that is not an EWREST body', async () => {
79-
inputValidationMockFns.mockSecureFetchWithPinnedIP
80-
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
81-
.mockResolvedValueOnce(
82-
mockSecureFetchResponse({ text: 'Error executing query, please consult logs' })
83-
)
84-
.mockResolvedValueOnce(mockSecureFetchResponse({}))
90+
it('trims the trailing space Agiloft puts on authentication_scheme', async () => {
91+
arrange(res({ json: { success: true, result: { id: 6342 } } }))
8592

86-
const response = await POST(createMockRequest('POST', baseBody))
87-
const data = (await response.json()) as { success: boolean; error?: string }
93+
await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
8894

89-
expect(data.success).toBe(false)
90-
expect(data.error).toContain('did not return a record ID')
95+
const [, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
96+
expect(init.headers.Authorization).toBe('Bearer tok-123')
9197
})
9298

93-
it('rejects a data payload that is not a JSON object', async () => {
94-
const response = await POST(
95-
createMockRequest('POST', { ...baseBody, data: '["not", "an", "object"]' })
99+
it('surfaces the live "One has to specify" refusal instead of a generic failure', async () => {
100+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
101+
res({
102+
ok: false,
103+
status: 400,
104+
text: '<html><body>EWWrongDataException has occurred: One has to specify $table, $KB, $lang parameters</body></html>',
105+
})
96106
)
107+
108+
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
97109
const data = (await response.json()) as { success: boolean; error?: string }
98110

99111
expect(data.success).toBe(false)
100-
expect(data.error).toContain('must be a JSON object')
101-
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
112+
expect(data.error).toContain('One has to specify $table, $KB, $lang')
102113
})
103114
})
104115

105-
describe('empty EWREST bodies on search and select', () => {
106-
const listBase = {
107-
instanceUrl: 'https://example.agiloft.com',
108-
knowledgeBase: 'Demo',
109-
login: 'admin',
110-
password: 'secret',
111-
table: 'helpdesk_case',
112-
}
116+
describe('alrest envelope handling', () => {
117+
it('targets /ewws/alrest/{KB} for record creation', async () => {
118+
arrange(res({ json: { success: true, result: { id: 6342, contract_title1: 'X' } } }))
113119

114-
function arrange(text: string) {
115-
inputValidationMockFns.mockSecureFetchWithPinnedIP
116-
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok' } }))
117-
.mockResolvedValueOnce(mockSecureFetchResponse({ text }))
118-
.mockResolvedValueOnce(mockSecureFetchResponse({}))
119-
}
120+
const response = await POST(
121+
createMockRequest('POST', { ...baseBody, data: '{"contract_title1":"X"}' })
122+
)
123+
const data = (await response.json()) as { success: boolean; output: { id: string | null } }
120124

121-
it('treats a plain-text refusal from EWSearch as a failure, not an empty result', async () => {
122-
arrange('Error executing query, please consult logs')
125+
const [url] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
126+
expect(url).toBe(
127+
'https://example.agiloft.com/ewws/alrest/Russell%20Investments/contract?lang=en'
128+
)
129+
expect(data.output.id).toBe('6342')
130+
})
123131

124-
const response = await SEARCH(
125-
createMockRequest('POST', { ...listBase, query: "priority='High'" })
132+
it('treats HTTP 200 with success:false as a failure, not a successful create', async () => {
133+
arrange(
134+
res({
135+
json: { success: false, errors: [{ message: 'Field contract_title1 is required' }] },
136+
})
126137
)
138+
139+
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
127140
const data = (await response.json()) as { success: boolean; error?: string }
128141

129142
expect(data.success).toBe(false)
130-
expect(data.error).toContain('did not return search results')
143+
expect(data.error).toContain('Field contract_title1 is required')
131144
})
145+
})
132146

133-
it('still reports a genuinely empty EWSearch result as a success', async () => {
134-
arrange("EWREST_id_length = '0';")
147+
describe('field projection', () => {
148+
it('reads through search when fields are named, so a 184KB record is not pulled whole', async () => {
149+
arrange(res({ json: { success: true, result: [{ id: 6342, contract_title1: 'X' }] } }))
135150

136-
const response = await SEARCH(
137-
createMockRequest('POST', { ...listBase, query: "priority='High'" })
151+
await READ(
152+
createMockRequest('POST', {
153+
...baseBody,
154+
recordId: '6342',
155+
fields: 'contract_title1, company_name',
156+
})
138157
)
139-
const data = (await response.json()) as {
140-
success: boolean
141-
output: { records: unknown[]; totalCount: number }
142-
}
143158

144-
expect(data.success).toBe(true)
145-
expect(data.output.records).toEqual([])
146-
expect(data.output.totalCount).toBe(0)
159+
const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
160+
expect(url).toContain('/contract/search?lang=en')
161+
expect(JSON.parse(init.body as string)).toEqual({
162+
field: ['id', 'contract_title1', 'company_name'],
163+
query: 'id=6342',
164+
})
147165
})
148166

149-
it('treats a plain-text refusal from EWSelect as a failure, not an empty result', async () => {
150-
arrange('Error executing query, please consult logs')
167+
it('fetches the record directly when no projection was asked for', async () => {
168+
arrange(res({ json: { success: true, result: { id: 6342 } } }))
151169

152-
const response = await SELECT(
153-
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
154-
)
155-
const data = (await response.json()) as { success: boolean; error?: string }
170+
await READ(createMockRequest('POST', { ...baseBody, recordId: '6342' }))
156171

157-
expect(data.success).toBe(false)
158-
expect(data.error).toContain('did not return a result set')
172+
const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
173+
expect(url).toContain('/contract/6342?lang=en')
174+
expect(init.method).toBe('GET')
159175
})
176+
})
160177

161-
it('still reports a genuinely empty EWSelect result as a success', async () => {
162-
arrange("EWREST_id_length = '0';")
178+
describe('optional inputs arriving as null', () => {
179+
it('accepts a null page instead of rejecting the call before it is made', async () => {
180+
arrange(res({ json: { success: true, result: [] } }))
163181

164-
const response = await SELECT(
165-
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
182+
const response = await SEARCH(
183+
createMockRequest('POST', {
184+
...baseBody,
185+
query: "status='Active'",
186+
page: null,
187+
limit: null,
188+
fields: null,
189+
search: null,
190+
})
166191
)
167-
const data = (await response.json()) as {
168-
success: boolean
169-
output: { recordIds: string[]; totalCount: number }
170-
}
192+
const data = (await response.json()) as { success: boolean; error?: string }
171193

172194
expect(data.success).toBe(true)
173-
expect(data.output.recordIds).toEqual([])
174-
expect(data.output.totalCount).toBe(0)
195+
expect(data.error).toBeUndefined()
196+
})
197+
198+
it('omits unset optional fields from the search body rather than sending null', async () => {
199+
arrange(res({ json: { success: true, result: [] } }))
200+
201+
await SEARCH(createMockRequest('POST', { ...baseBody, query: "status='Active'", page: null }))
202+
203+
const [, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
204+
expect(JSON.parse(init.body as string)).toEqual({ query: "status='Active'" })
175205
})
176206
})

0 commit comments

Comments
 (0)