From fe3d3987574455233d08b7e35ce0b434bfa2886e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 1 Sep 2026 03:41:28 +0000 Subject: [PATCH] fix(eims): sign the /v1/verify request body --- .../modules/eims/eims-client.service.spec.ts | 140 ++++++++++++++++++ .../src/modules/eims/eims-client.service.ts | 15 +- .../eims-invoice-registration.service.spec.ts | 72 +++++---- .../eims/eims-invoice-registration.service.ts | 7 +- 4 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts new file mode 100644 index 000000000..4bb20d80e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts @@ -0,0 +1,140 @@ +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsSignerService } from "./eims-signer.service"; +import { eimsConfig } from "./eims-test-fixtures"; + +const API_KEY = "super-secret-apikey"; +const CLIENT_SECRET = "super-secret-value"; +const TOKEN = "access-token-value"; + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = eimsConfig(), token: string = TOKEN) => + new EimsClientService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + { + getValidAccessToken: jest.fn().mockResolvedValue(token), + invalidate: jest.fn(), + } as unknown as EimsAuthService, + signer, + ); + +const ok = (data: unknown = { statusCode: 200, body: { Irn: "irn-echoed" } }) => + jest.fn().mockReturnValue(of({ data })); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +/** `post(url, body, config)` — the config argument every assertion below reads. */ +const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2]; +const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1]; + +describe("EimsClientService transport", () => { + it("bearer-authenticates every protected call", async () => { + const post = ok(); + await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + + expect(sentConfig(post).headers).toEqual({ + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + }); + }); + + it("sends the same headers on a signed call", async () => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", { Invoice: 1 }); + + expect(sentConfig(post).headers).toEqual({ + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + }); + }); + + it("wraps a signed call in the {request,signature,certificate} envelope", async () => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", { Invoice: 1 }); + + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request: { Invoice: 1 }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("leaves an unsigned body verbatim", async () => { + const post = ok(); + await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + + // Raw object, not the JSON string `toSignedBody` produces. + expect(sentBody(post)).toEqual({ Irn: "irn-1" }); + }); + + it("re-signs and re-authenticates through the one 401 retry", async () => { + const post = jest + .fn() + .mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" }))) + .mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } })); + + await build(post).postSigned("/v1/verify", { irn: "irn-1" }); + + expect(post).toHaveBeenCalledTimes(2); + expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(JSON.parse(sentBody(post, 1) as string)).toMatchObject({ + request: { irn: "irn-1" }, + signature: "SIGNATURE", + }); + }); + + it("never leaks the api key, bearer token or client secret into a thrown failure", async () => { + const post = jest.fn().mockReturnValue( + throwError(() => + // The live shape of an unsigned /v1/verify rejection, as observed on 2026-09-01. + axiosErr(400, { + message: "GATEWAY ERROR", + code: "4001", + details: [ + { field: "certificate", errorMessage: "must not be null" }, + { field: "signature", errorMessage: "must not be null" }, + { field: "request", errorMessage: "must not be null" }, + ], + // An echoed request is exactly what redaction has to drop. + request: { apikey: API_KEY, clientSecret: CLIENT_SECRET }, + }), + ), + ); + + const error: Error = await build(post) + .postSigned("/v1/verify", { irn: "irn-1" }) + .then(() => { + throw new Error("expected the call to reject"); + }) + .catch((err: Error) => err); + + const serialized = JSON.stringify({ + message: error.message, + response: (error as { getResponse?: () => unknown }).getResponse?.(), + details: (error as { details?: unknown }).details, + }); + expect(serialized).not.toContain(API_KEY); + expect(serialized).not.toContain(CLIENT_SECRET); + expect(serialized).not.toContain(TOKEN); + // The gateway's own reporting still survives redaction. + expect(error.message).toContain("4001"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 610647b1a..1870814c5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -11,7 +11,7 @@ import { toEimsApiException } from "./eims.errors"; * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). * * Login is not routed through here: `/auth/login` carries no bearer token and lives in - * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + * `EimsAuthService`. */ @Injectable() export class EimsClientService { @@ -39,10 +39,15 @@ export class EimsClientService { /** * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. * - * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a - * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point - * so that if the live gateway turns out to require signing after all, exactly one call site - * changes — `postSigned` is already the alternative. + * The supplied collection sends raw bodies for `/v1/verify`, `/v1/cancel` and the receipt + * endpoints. That turned out to be wrong for `/v1/verify`, which the live gateway rejects with + * `GATEWAY ERROR code=4001` (`request`/`signature`/`certificate` must not be null) until the + * envelope is added — so verify now uses `postSigned`. + * + * The remaining callers (cancel, bulk cancel, receipts) still send raw bodies and have **not** + * been exercised against the live gateway. Each is a candidate for the same rejection; none can + * be probed safely, because unlike verify they all mutate state at MoR. Expect to convert them + * the same way the first time one is filed for real. */ async postBearer(path: string, request: TRequest): Promise { return this.send(path, request, false, false); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 69a4d5ffd..8f8a94a7c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -759,38 +759,60 @@ describe("EimsInvoiceRegistrationService staff alerting", () => { }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { - it("verifies the stored IRN over the unsigned bearer transport", async () => { + it("verifies the stored IRN over the signed transport", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postSigned = jest.fn(); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postBearer = jest.fn(); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); - // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. - expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); - expect(postSigned).not.toHaveBeenCalled(); + // Lowercase `irn`, signed envelope — the live gateway rejects the unsigned body with + // `code=4001` naming request/signature/certificate as null. + expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postBearer).not.toHaveBeenCalled(); expect(result.body).toMatchObject({ Irn: IRN }); }); it("rejects a 200 that carries no Irn", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/returned no Irn/); }); it("refuses to verify an invoice with no IRN", async () => { const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); - const postBearer = jest.fn(); + const postSigned = jest.fn(); await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/no EIMS IRN to verify/); - expect(postBearer).not.toHaveBeenCalled(); + expect(postSigned).not.toHaveBeenCalled(); + }); + + it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const before = { ...db.invoices.get(INVOICE_ID)! }; + const stateBefore = { ...db.state! }; + // The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document + // that is already registered. Verification is a read — a failed read must never downgrade the + // registration or move the counter. + const postSigned = jest + .fn() + .mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400)); + + await expect( + build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/4001/); + + expect(db.invoices.get(INVOICE_ID)).toEqual(before); + expect(db.state).toEqual(stateBefore); }); }); @@ -813,15 +835,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("records a confirmed IRN, resumes the chain and clears the block", async () => { const db = blocked(); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); - const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( INVOICE_ID, { irn: IRN }, ); // The IRN is confirmed at the gateway before it is ever written. - expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); expect(db.state).toMatchObject({ previousIrn: IRN, @@ -832,12 +854,12 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { const db = blocked(); - const postBearer = jest + const postSigned = jest .fn() .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/answered the lookup for IRN/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -853,14 +875,14 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); - const postBearer = jest.fn().mockResolvedValue( + const postSigned = jest.fn().mockResolvedValue( verifyResponse({ DocumentDetails: { Type: "INV", DocumentNumber: "99999" }, }), ); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/not 5/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -876,25 +898,25 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway does not acknowledge at all", async () => { const db = blocked(); - const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/returned no Irn/); expect(db.state!.blockedReason).toBe("never acknowledged"); }); it("discards the attempt, leaving the chain where it was", async () => { const db = blocked(); - const postBearer = jest.fn(); + const postSigned = jest.fn(); - const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( INVOICE_ID, { discard: true }, ); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); - expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(postSigned).not.toHaveBeenCalled(); // nothing to confirm expect(db.state).toMatchObject({ previousIrn: null, inFlightInvoiceId: null, @@ -908,10 +930,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }), ); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(OTHER_INVOICE_ID, { irn: IRN, }), ).rejects.toThrow(/in-flight EIMS submission is invoice/); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index b705901d6..65221d48c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -214,10 +214,13 @@ export class EimsInvoiceRegistrationService { * compared — the supplied collection's own fixture uses different example values on each side, * so equality there would assert a property of the mock rather than of the gateway. * - * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + * Signed, via `postSigned`. The supplied collection shows a raw `{"irn":"…"}` body, but the live + * gateway rejects that with `GATEWAY ERROR code=4001` naming `request`, `signature` and + * `certificate` as null — verified against `core.mor.gov.et` on 2026-09-01. The signed envelope + * clears that validation. The collection's unsigned example is wrong for this endpoint. */ private async queryVerify(irn: string): Promise { - const response = await this.client.postBearer( + const response = await this.client.postSigned( "/v1/verify", { irn }, );