diff --git a/packages/backend/src/adapters/de/lexware-office.json b/packages/backend/src/adapters/de/lexware-office.json index 73f5f6cd..1b517bc9 100644 --- a/packages/backend/src/adapters/de/lexware-office.json +++ b/packages/backend/src/adapters/de/lexware-office.json @@ -109,11 +109,11 @@ "properties": { "voucherType": { "type": "string", - "description": "Required by Lexware. Comma-separated types: salesinvoice, salescreditnote, purchaseinvoice, purchasecreditnote, invoice, creditnote, orderconfirmation, quotation, deliverynote, downpaymentinvoice, or any." + "description": "Required by Lexware. Comma-separated list, or `any` for all types: salesinvoice, salescreditnote, purchaseinvoice, purchasecreditnote (bookkeeping vouchers: read one with lexware_office_get_voucher), invoice (lexware_office_get_invoice), creditnote (lexware_office_get_credit_note), quotation (lexware_office_get_quotation), downpaymentinvoice, orderconfirmation, deliverynote (lexware_office_get_sales_document)." }, "voucherStatus": { "type": "string", - "description": "Required by Lexware. Comma-separated statuses: draft, open, paid, paidoff, voided, transferred, sepadebit, overdue, accepted, rejected, or any. overdue cannot be combined with other statuses: ask for it on its own (Lexware answers 400 otherwise)." + "description": "Required by Lexware. Comma-separated list, or `any`: draft, open, paid, paidoff, voided, transferred, sepadebit, accepted/rejected (quotations only), unchecked (bookkeeping vouchers only), overdue. `overdue` must be sent ALONE (voucherStatus=overdue), never combined with other statuses: Lexware answers 400 otherwise. Overdue vouchers are already included in `open`, so for 'everything unpaid' use `open,sepadebit,transferred`." }, "archived": { "type": "boolean", @@ -137,7 +137,31 @@ }, "size": { "type": "number", - "description": "Page size, 1–250 (default 25)." + "description": "Page size, 1-250 (default 25). Lexware returns at most 10,000 results in total; narrow by date beyond that." + }, + "voucherNumber": { + "type": "string", + "description": "Exact voucher number, e.g. RE0042." + }, + "createdDateFrom": { + "type": "string", + "description": "Created on or after, YYYY-MM-DD." + }, + "createdDateTo": { + "type": "string", + "description": "Created on or before, YYYY-MM-DD." + }, + "updatedDateFrom": { + "type": "string", + "description": "Updated on or after, YYYY-MM-DD." + }, + "updatedDateTo": { + "type": "string", + "description": "Updated on or before, YYYY-MM-DD." + }, + "sort": { + "type": "string", + "description": "voucherDate, voucherNumber, createdDate or updatedDate, optionally followed by ,ASC or ,DESC, e.g. voucherDate,DESC." } }, "required": [ @@ -156,7 +180,13 @@ "voucherDateFrom": "$voucherDateFrom", "voucherDateTo": "$voucherDateTo", "page": "$page", - "size": "$size" + "size": "$size", + "voucherNumber": "$voucherNumber", + "createdDateFrom": "$createdDateFrom", + "createdDateTo": "$createdDateTo", + "updatedDateFrom": "$updatedDateFrom", + "updatedDateTo": "$updatedDateTo", + "sort": "$sort" } } }, @@ -168,7 +198,7 @@ "properties": { "id": { "type": "string", - "description": "Invoice UUID, as returned in the voucher list's `id` field." + "description": "Invoice UUID. Only for voucher-list rows whose voucherType is `invoice`. For salesinvoice, purchaseinvoice, salescreditnote or purchasecreditnote use lexware_office_get_voucher; for downpaymentinvoice, orderconfirmation or deliverynote use lexware_office_get_sales_document. A wrong pairing answers 404." } }, "required": [ @@ -220,6 +250,57 @@ "path": "/credit-notes/{id}" } }, + { + "name": "lexware_office_get_voucher", + "description": "Read one bookkeeping voucher (voucher-list voucherType salesinvoice, salescreditnote, purchaseinvoice or purchasecreditnote): contact, voucher items grouped by tax rate, totals, due date and attached file ids.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Voucher UUID from lexware_office_list_vouchers whose voucherType is salesinvoice, salescreditnote, purchaseinvoice or purchasecreditnote." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/vouchers/{id}" + } + }, + { + "name": "lexware_office_get_sales_document", + "description": "Read one down payment invoice, order confirmation, delivery note or dunning by id.", + "parameters": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "enum": [ + "down-payment-invoices", + "order-confirmations", + "delivery-notes", + "dunnings" + ], + "description": "downpaymentinvoice → down-payment-invoices, orderconfirmation → order-confirmations, deliverynote → delivery-notes." + }, + "id": { + "type": "string", + "description": "UUID from the voucher list." + } + }, + "required": [ + "resource", + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{resource}/{id}" + } + }, { "name": "lexware_office_list_articles", "description": "List articles (Artikel) from the product catalogue, optionally narrowed by article number or type, so an invoice line can reference a real product.", @@ -269,29 +350,24 @@ }, { "name": "lexware_office_create_contact", - "description": "Create a new contact with the customer and/or vendor role. Writes to the live company — check with lexware_office_list_contacts first that the party does not already exist.", + "description": "Create a new company contact with the customer and/or vendor role. Writes to the live company: check with lexware_office_list_contacts first that the party does not already exist.", "parameters": { "type": "object", "properties": { "companyName": { "type": "string", - "description": "Company name. Provide this OR firstName+lastName, not both." - }, - "firstName": { - "type": "string", - "description": "Given name, for a private person." + "description": "Company name." }, - "lastName": { - "type": "string", - "description": "Family name, for a private person." - }, - "isCustomer": { - "type": "boolean", - "description": "Give the contact the customer role (default true)." + "roles": { + "type": "object", + "default": { + "customer": {} + }, + "description": "Roles as an object: {\"customer\":{}} (default), {\"vendor\":{}}, or both {\"customer\":{},\"vendor\":{}}." }, - "isVendor": { - "type": "boolean", - "description": "Give the contact the vendor role." + "billingAddress": { + "type": "object", + "description": "Billing address: {\"street\":\"Musterstr. 1\",\"zip\":\"79098\",\"city\":\"Freiburg\",\"countryCode\":\"DE\"}. countryCode (ISO 3166-1 alpha-2) is required when an address is given. Omit for no address." }, "email": { "type": "string", @@ -300,22 +376,6 @@ "phone": { "type": "string", "description": "Primary business phone number." - }, - "street": { - "type": "string", - "description": "Street and house number of the billing address." - }, - "zip": { - "type": "string", - "description": "Postal code of the billing address." - }, - "city": { - "type": "string", - "description": "City of the billing address." - }, - "countryCode": { - "type": "string", - "description": "ISO 3166-1 alpha-2 country code, e.g. DE." } }, "required": [ @@ -327,25 +387,13 @@ "path": "/contacts", "bodyMapping": { "version": 0, - "roles": { - "customer": {}, - "vendor": {} - }, + "roles": "$roles", "company": { "name": "$companyName" }, - "person": { - "firstName": "$firstName", - "lastName": "$lastName" - }, "addresses": { "billing": [ - { - "street": "$street", - "zip": "$zip", - "city": "$city", - "countryCode": "$countryCode" - } + "$billingAddress" ] }, "emailAddresses": { diff --git a/packages/backend/src/adapters/de/lexware-office.live.spec.ts b/packages/backend/src/adapters/de/lexware-office.live.spec.ts index 34c91822..db6604f2 100644 --- a/packages/backend/src/adapters/de/lexware-office.live.spec.ts +++ b/packages/backend/src/adapters/de/lexware-office.live.spec.ts @@ -38,6 +38,22 @@ describe('lexware-office adapter — static spec conformance', () => { it("exposes the voucher list, which is what 'what is unpaid' needs", () => { expect(toolNames).toContain('lexware_office_list_vouchers'); }); + + // Voucher-list rows of type salesinvoice/purchaseinvoice/... live under + // /vouchers/{id}; sending them to /invoices/{id} answered 404 for a user. + it('reads bookkeeping vouchers and the other sales documents at their own endpoints', () => { + const path = (n: string) => a.tools.find((t) => t.name === n)?.endpointMapping.path; + expect(path('lexware_office_get_voucher')).toBe('/vouchers/{id}'); + expect(path('lexware_office_get_sales_document')).toBe('/{resource}/{id}'); + }); + + it('creates a contact with the roles asked for, and no empty address or person', () => { + const body = a.tools.find((t) => t.name === 'lexware_office_create_contact')!.endpointMapping + .bodyMapping as Record; + expect(body.roles).toBe('$roles'); + expect(body.person).toBeUndefined(); + expect(body.addresses).toEqual({ billing: ['$billingAddress'] }); + }); }); // Opt-in live check. Needs real credentials; skipped in CI. diff --git a/packages/backend/src/adapters/intl/odoo-jsonrpc.json b/packages/backend/src/adapters/intl/odoo-jsonrpc.json index 975f22b4..240a6ac9 100644 --- a/packages/backend/src/adapters/intl/odoo-jsonrpc.json +++ b/packages/backend/src/adapters/intl/odoo-jsonrpc.json @@ -6,7 +6,7 @@ "category": "erp", "icon": "odoo", "docsUrl": "https://www.odoo.com/documentation/17.0/developer/reference/external_api.html", - "instructions": "**Which Odoo connector?** This one is for **Odoo 14 to 18** (and works on 19 too). It talks to Odoo's classic JSON-RPC endpoint `/jsonrpc`. For Odoo 19 and newer, the \"Odoo\" connector (JSON-2 API) is the simpler choice: it needs no user id. Your version is shown at the bottom of **Settings**.\n\n**What you need (four values)**\n1. `ODOO_URL`: the address of your Odoo, scheme and host only, e.g. `https://erp.example.com` or `https://mycompany.odoo.com`. Not the address of a page inside Odoo: nothing after the host, no `/web`, no `?db=` or `#action=`.\n2. `ODOO_DB`: the database name. On Odoo Online it is usually the subdomain. On your own server it is the name shown on the database selector, or the value after `?db=` in the address bar.\n3. `ODOO_API_KEY`: in Odoo open your user menu → **Preferences** (Odoo 14/15: **My Profile**) → **Account Security** → **New API Key**. Odoo shows the key once. If the Account Security tab or the API key button is missing, turn on developer mode (Settings → Activate the developer mode). The key replaces your password for API calls; your login password itself also works but is not recommended.\n4. `ODOO_UID`: the numeric id of the Odoo user the key belongs to. Open **Settings → Users & Companies → Users**, open that user, and read the number after `id=` in the address bar (e.g. `...#id=7&model=res.users` → `7`).\n\nThe key, the user id and the database must belong together: a key of user 7 with `ODOO_UID=2` is refused.\n\n**Errors**: Odoo answers JSON-RPC errors with HTTP 200 and an `error` object (`Access Denied`, `Invalid field 'x' on model 'y'`, `Record does not exist`). Read the `error.data.message` of the response.\n\n**Domains are Odoo's query language.** A domain is a list of triples: `[[\"state\",\"=\",\"sale\"],[\"amount_total\",\">\",1000]]`, implicitly AND-ed. `|` and `!` prefix operators express OR and NOT.\n\n**Always pass `fields`** to `odoo_search_read` and `odoo_read`: Odoo models have hundreds of columns. Start with `odoo_fields_get` to see what exists; field names differ between Odoo versions (e.g. `mobile` on res.partner is gone in 19).\n\n**Permissions follow the user.** The key inherits its owner's access rights and record rules. A restricted user sees fewer rows, not an error.\n\n**Self-hosted**: on **AnythingMCP Cloud** the instance must be reachable from the public internet with a valid TLS certificate. On an internal host, self-host AnythingMCP on the same network and add the host to `SSRF_ALLOWED_HOSTS`.\n\n**Writes**: `odoo_create`, `odoo_write` and `odoo_call_method` change the live database, and Odoo's automations fire as if a person had done it.", + "instructions": "**Which Odoo connector?** This one is for **Odoo 14 to 18** (and works on 19 too). It talks to Odoo's classic JSON-RPC endpoint `/jsonrpc`. For Odoo 19 and newer, the \"Odoo\" connector (JSON-2 API) is the simpler choice: it needs no user id. Your version is shown at the bottom of **Settings**.\n\n**What you need (four values)**\n1. `ODOO_URL`: the address of your Odoo, scheme and host only, e.g. `https://erp.example.com` or `https://mycompany.odoo.com`. Not the address of a page inside Odoo: nothing after the host, no `/web`, no `?db=` or `#action=`.\n2. `ODOO_DB`: the database name. On Odoo Online it is usually the subdomain. On your own server it is the name shown on the database selector, or the value after `?db=` in the address bar.\n3. `ODOO_API_KEY`: in Odoo open your user menu → **Preferences** (Odoo 14/15: **My Profile**) → **Account Security** → **New API Key**. Odoo shows the key once. If the Account Security tab or the API key button is missing, turn on developer mode (Settings → Activate the developer mode). The key replaces your password for API calls; your login password itself also works but is not recommended.\n4. `ODOO_UID`: the numeric id of the Odoo user the key belongs to. Open **Settings → Users & Companies → Users**, open that user, and read the number after `id=` in the address bar (e.g. `...#id=7&model=res.users` → `7`).\n\nThe key, the user id and the database must belong together: a key of user 7 with `ODOO_UID=2` is refused.\n\n**Errors**: Odoo answers JSON-RPC errors inside an HTTP 200 with an `error` object. AnythingMCP raises them as errors carrying Odoo's own message: `Access Denied` (wrong key, user id or database), `Invalid field 'x' on model 'y'` (field names differ between versions), `Record does not exist`.\n\n**Domains are Odoo's query language.** A domain is a list of triples: `[[\"state\",\"=\",\"sale\"],[\"amount_total\",\">\",1000]]`, implicitly AND-ed. `|` and `!` prefix operators express OR and NOT.\n\n**Always pass `fields`** to `odoo_search_read` and `odoo_read`: Odoo models have hundreds of columns. Start with `odoo_fields_get` to see what exists; field names differ between Odoo versions (e.g. `mobile` on res.partner is gone in 19).\n\n**Permissions follow the user.** The key inherits its owner's access rights and record rules. A restricted user sees fewer rows, not an error.\n\n**Self-hosted**: on **AnythingMCP Cloud** the instance must be reachable from the public internet with a valid TLS certificate. On an internal host, self-host AnythingMCP on the same network and add the host to `SSRF_ALLOWED_HOSTS`.\n\n**Writes**: `odoo_create`, `odoo_write` and `odoo_call_method` change the live database, and Odoo's automations fire as if a person had done it.", "requiredEnvVars": [ "ODOO_URL", "ODOO_DB", diff --git a/packages/backend/src/adapters/intl/zabbix.json b/packages/backend/src/adapters/intl/zabbix.json index d44aef69..26921d34 100644 --- a/packages/backend/src/adapters/intl/zabbix.json +++ b/packages/backend/src/adapters/intl/zabbix.json @@ -6,7 +6,7 @@ "category": "monitoring", "icon": "zabbix", "docsUrl": "https://www.zabbix.com/documentation/current/en/manual/api", - "instructions": "**Getting a token** (requires Zabbix 6.4 or newer)\n1. Sign in to the Zabbix frontend as a Super admin and open **Users → API tokens**.\n2. Create a token for a user with read permission on the host groups the agent should see, and copy the value — Zabbix shows it once.\n3. Set `ZABBIX_URL` to your frontend root (no trailing slash, no `/api_jsonrpc.php`) and `ZABBIX_API_TOKEN` to the token. The adapter sends it as `Authorization: Bearer …`, which Zabbix accepts from 6.4 on. Zabbix 5.4–6.2 only take the token in the JSON-RPC `auth` field, which this adapter does not use, so every call there fails with \"Not authorised\".\n\n**Errors arrive as HTTP 200.** JSON-RPC reports a failure inside a successful response, as `{\"error\": {\"code\": …, \"message\": …}}`. Read the body, not the status. The *Test connection* button only proves the URL answers; `zabbix_get_api_version` is the real check of URL plus token.\n\n**One endpoint, many methods.** Zabbix is JSON-RPC: every call is a POST to `/api_jsonrpc.php` with `{\"jsonrpc\":\"2.0\",\"method\":\"host.get\",\"params\":{…},\"id\":1}`. That is why every tool here has the same path, and why the *method* is what distinguishes them.\n\n**Permissions are per host group, and Zabbix reports a denial as an empty array.** A token whose user cannot see a host group gets `{\"result\": []}` rather than an error — so an agent that reports 'no problems' may simply be looking through an account with no visibility.\n\n**Severity is an integer.** 0 not classified, 1 information, 2 warning, 3 average, 4 high, 5 disaster. 'Critical alerts' usually means severity ≥ 4.\n\n**`problem.get` is the live board, `event.get` is the history.** A problem that has since resolved is gone from `problem.get` and still in `event.get`. Ask the right one.\n\n**Timestamps are Unix seconds**, in the server's timezone configuration.\n\n**Self-hosted, which is the thing to plan for.**\n- On **AnythingMCP Cloud** the instance must be reachable from the public internet on a real hostname with a valid TLS certificate. A self-signed certificate will fail: the connector offers no trust-anything switch, and a homelab's default certificate is exactly the case that breaks.\n- On an internal host (`zabbix.intern`, `10.0.0.x`), self-host AnythingMCP on the same network and add that host to `SSRF_ALLOWED_HOSTS`, or the outbound guard refuses the call before it is made.\n", + "instructions": "**Getting a token** (requires Zabbix 6.4 or newer)\n1. Sign in to the Zabbix frontend as a Super admin and open **Users → API tokens**.\n2. Create a token for a user with read permission on the host groups the agent should see, and copy the value — Zabbix shows it once.\n3. Set `ZABBIX_URL` to your frontend root (no trailing slash, no `/api_jsonrpc.php`) and `ZABBIX_API_TOKEN` to the token. The adapter sends it as `Authorization: Bearer …`, which Zabbix accepts from 6.4 on. Zabbix 5.4–6.2 only take the token in the JSON-RPC `auth` field, which this adapter does not use, so every call there fails with \"Not authorised\".\n\n**Errors.** Zabbix's JSON-RPC reports a failure inside an HTTP 200, as `{\"error\": {\"code\": …, \"message\": …, \"data\": …}}`. AnythingMCP turns that into an error carrying Zabbix's own message, e.g. `JSON-RPC error -32602: Invalid params.: Not authorised.` The *Test connection* button only proves the frontend answers at that URL; `zabbix_get_api_version` is the real check of URL plus token.\n\n**One endpoint, many methods.** Zabbix is JSON-RPC: every call is a POST to `/api_jsonrpc.php` with `{\"jsonrpc\":\"2.0\",\"method\":\"host.get\",\"params\":{…},\"id\":1}`. That is why every tool here has the same path, and why the *method* is what distinguishes them.\n\n**Permissions are per host group, and Zabbix reports a denial as an empty array.** A token whose user cannot see a host group gets `{\"result\": []}` rather than an error — so an agent that reports 'no problems' may simply be looking through an account with no visibility.\n\n**Severity is an integer.** 0 not classified, 1 information, 2 warning, 3 average, 4 high, 5 disaster. 'Critical alerts' usually means severity ≥ 4.\n\n**`problem.get` is the live board, `event.get` is the history.** A problem that has since resolved is gone from `problem.get` and still in `event.get`. Ask the right one.\n\n**Timestamps are Unix seconds**, in the server's timezone configuration.\n\n**Self-hosted, which is the thing to plan for.**\n- On **AnythingMCP Cloud** the instance must be reachable from the public internet on a real hostname with a valid TLS certificate. A self-signed certificate will fail: the connector offers no trust-anything switch, and a homelab's default certificate is exactly the case that breaks.\n- On an internal host (`zabbix.intern`, `10.0.0.x`), self-host AnythingMCP on the same network and add that host to `SSRF_ALLOWED_HOSTS`, or the outbound guard refuses the call before it is made.\n", "requiredEnvVars": [ "ZABBIX_URL", "ZABBIX_API_TOKEN" @@ -22,7 +22,7 @@ "headers": { "Content-Type": "application/json-rpc" }, - "healthcheckPath": "/api_jsonrpc.php" + "healthcheckPath": "/" }, "probe": { "tool": "zabbix_get_api_version" diff --git a/packages/backend/src/adapters/intl/zabbix.live.spec.ts b/packages/backend/src/adapters/intl/zabbix.live.spec.ts index 04ac572b..d8e2e9cf 100644 --- a/packages/backend/src/adapters/intl/zabbix.live.spec.ts +++ b/packages/backend/src/adapters/intl/zabbix.live.spec.ts @@ -28,6 +28,12 @@ describe('zabbix adapter — static spec conformance', () => { }; }); + // A GET on /api_jsonrpc.php answers a JSON-RPC "Parse error", which the + // engine now raises: Test connection must not point there. + it("checks the connection on the frontend, not on the JSON-RPC endpoint", () => { + expect(a.connector.healthcheckPath).toBe('/'); + }); + it("names a distinct JSON-RPC method per tool", () => { const methods = a.tools.map((t) => (t.endpointMapping.bodyMapping as Record).method); expect(methods).toEqual(expect.arrayContaining(['hostgroup.get', 'host.get', 'problem.get', 'event.get'])); diff --git a/packages/backend/src/connectors/engines/rest.engine.jsonrpc.spec.ts b/packages/backend/src/connectors/engines/rest.engine.jsonrpc.spec.ts new file mode 100644 index 00000000..be66a48b --- /dev/null +++ b/packages/backend/src/connectors/engines/rest.engine.jsonrpc.spec.ts @@ -0,0 +1,70 @@ +import { createServer, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { JsonRpcError, RestEngine, assertNotJsonRpcError } from './rest.engine'; + +/** + * JSON-RPC servers answer errors with HTTP 200. Odoo's and Zabbix's real + * shapes, as logged in production (Oct 2026), must surface as errors. + */ +describe('assertNotJsonRpcError', () => { + it("raises Odoo's error with the message from error.data, not the traceback", () => { + const odoo = { + jsonrpc: '2.0', + id: null, + error: { + code: 200, + message: 'Odoo Server Error', + data: { name: 'odoo.exceptions.AccessDenied', debug: 'Traceback (most recent call last): ...', message: 'Access Denied' }, + }, + }; + expect(() => assertNotJsonRpcError(odoo)).toThrow(new JsonRpcError('JSON-RPC error 200: Odoo Server Error: Access Denied', 200)); + expect(() => assertNotJsonRpcError(odoo)).not.toThrow(/Traceback/); + }); + + it("raises Zabbix's error, whose detail is a plain string", () => { + const zabbix = { jsonrpc: '2.0', id: 1, error: { code: -32602, message: 'Invalid params.', data: 'Not authorised.' } }; + expect(() => assertNotJsonRpcError(zabbix)).toThrow('JSON-RPC error -32602: Invalid params.: Not authorised.'); + }); + + it('lets results, ordinary bodies and batch arrays through', () => { + expect(() => assertNotJsonRpcError({ jsonrpc: '2.0', id: 1, result: [] })).not.toThrow(); + expect(() => assertNotJsonRpcError({ jsonrpc: '2.0', id: 1, result: false })).not.toThrow(); + expect(() => assertNotJsonRpcError({ error: 'not found' })).not.toThrow(); + expect(() => assertNotJsonRpcError({ error: { message: 'x' } })).not.toThrow(); + expect(() => assertNotJsonRpcError([{ jsonrpc: '2.0', error: { code: 1 } }])).not.toThrow(); + expect(() => assertNotJsonRpcError('text')).not.toThrow(); + expect(() => assertNotJsonRpcError(null)).not.toThrow(); + }); +}); + +describe('RestEngine on a JSON-RPC endpoint', () => { + let server: Server; + let baseUrl: string; + let reply: unknown; + + beforeAll(async () => { + server = createServer((_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(reply)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + const engine = new RestEngine({} as any, {} as any); + const call = () => + engine.execute({ baseUrl, authType: 'NONE' }, { method: 'POST', path: '/jsonrpc', bodyMapping: { jsonrpc: '2.0' } }, {}); + + it('rejects an HTTP 200 that carries an error', async () => { + reply = { jsonrpc: '2.0', id: 1, error: { code: 200, message: 'Odoo Server Error', data: { message: 'Access Denied' } } }; + await expect(call()).rejects.toThrow('Access Denied'); + }); + + it('returns the envelope of a successful call unchanged', async () => { + reply = { jsonrpc: '2.0', id: 1, result: [{ id: 7 }] }; + await expect(call()).resolves.toEqual(reply); + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index 486bf05c..0f5043fb 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -108,13 +108,17 @@ export class RestEngine { }, params: Record, ): Promise<{ body: unknown; headers: Record }> { - const withMeta = (response: AxiosResponse) => ({ - body: endpointMapping.rawBody ? response.data : parseXmlBody(response), - headers: pickExposedHeaders( - response.headers as Record, - endpointMapping.exposeHeaders, - ), - }); + const withMeta = (response: AxiosResponse) => { + const body = endpointMapping.rawBody ? response.data : parseXmlBody(response); + assertNotJsonRpcError(body); + return { + body, + headers: pickExposedHeaders( + response.headers as Record, + endpointMapping.exposeHeaders, + ), + }; + }; // Interpolate path parameters: /users/{id} → /users/123 // // `path` is optional on the stored mapping — tools saved as `method: @@ -1148,6 +1152,46 @@ function assertNoPrototypePollution(value: unknown): void { * zyte-request-id is included because it is the first thing Zyte support asks * for, and it is not recoverable after the fact. */ +/** + * JSON-RPC servers (Odoo's /jsonrpc, Zabbix's api_jsonrpc.php) answer an + * error with HTTP 200 and an `error` member instead of `result`. Passed on as + * a body, such a call was logged as a success and the model had to notice the + * error itself; a connector test passed with a wrong key. Raise it instead, + * with the server's own message (Odoo puts the useful part in + * error.data.message, Zabbix in error.data) and without the traceback. + */ +export class JsonRpcError extends Error { + constructor( + message: string, + readonly code: unknown, + ) { + super(message); + this.name = 'JsonRpcError'; + } +} + +export function assertNotJsonRpcError(body: unknown): void { + if (!body || typeof body !== 'object' || Array.isArray(body)) return; + const envelope = body as { jsonrpc?: unknown; error?: unknown; result?: unknown }; + if (typeof envelope.jsonrpc !== 'string') return; + if (!envelope.error || typeof envelope.error !== 'object') return; + if (envelope.result !== undefined) return; + const error = envelope.error as { code?: unknown; message?: unknown; data?: unknown }; + const data = error.data as { message?: unknown; name?: unknown } | string | undefined; + const detail = + typeof data === 'string' + ? data + : data && typeof data === 'object' && typeof data.message === 'string' + ? data.message + : undefined; + const head = typeof error.message === 'string' && error.message ? error.message : 'JSON-RPC error'; + const text = detail && detail !== head ? `${head}: ${detail}` : head; + throw new JsonRpcError( + `JSON-RPC error${error.code !== undefined ? ` ${String(error.code)}` : ''}: ${text.slice(0, 1000)}`, + error.code, + ); +} + export function restateProxyError(error: unknown): unknown { if (!(error instanceof AxiosError) || !error.response) return error; const headers = error.response.headers as Record | undefined;