diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index a6aa3895d..5e89822f4 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -26,6 +26,20 @@ const withEnv = (vars: Record, fn: () => void) => { }; describe("eims.config — private key / certificate resolution", () => { + it("requires EIMS_API_KEY when EIMS is enabled without exposing a value", () => { + withEnv( + { + ...REQUIRED, + EIMS_API_KEY: undefined, + EIMS_PRIVATE_KEY: "private-key-present", + EIMS_CERTIFICATE: "certificate-present", + }, + () => { + expect(() => eimsConfigFactory()).toThrow(/env vars are missing: EIMS_API_KEY/); + }, + ); + }); + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { withEnv( { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, 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 index 4bb20d80e..3d68e180e 100644 --- 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 @@ -1,4 +1,5 @@ import { HttpService } from "@nestjs/axios"; +import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; @@ -46,24 +47,48 @@ 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" }); + const protectedHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + apikey: API_KEY, + }; - expect(sentConfig(post).headers).toEqual({ - "Content-Type": "application/json", - Authorization: `Bearer ${TOKEN}`, + it.each([ + ["verify", "/v1/verify", { irn: "irn-1" }], + ["sales receipt", "/v1/receipt/sales", { receipt: "sales" }], + ["withholding receipt", "/v1/receipt/withholding", { receipt: "withholding" }], + ["cancel", "/v1/cancel", { Irn: "irn-1" }], + ["bulk cancel", "/v1/bulkCancel", [{ Irn: "irn-1" }]], + ])("authenticates the raw %s endpoint without changing its body", async (_name, path, body) => { + const post = ok(); + await build(post).postBearer(path, body); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(sentBody(post)).toBe(body); + }); + + it.each([ + ["invoice", { DocumentDetails: { Type: "INV" } }], + ["credit memo", { DocumentDetails: { Type: "CRE" } }], + ["debit memo", { DocumentDetails: { Type: "DEB" } }], + ])("authenticates and signs a %s registration", async (_name, request) => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", request); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request, + signature: "SIGNATURE", + certificate: "CERTIFICATE", }); }); - 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 }); + it("authenticates bulk registration through the same signed path", async () => { + const post = ok({ conversationId: "conversation-1", status: 202 }); + const request = [{ DocumentDetails: { Type: "INV" } }]; + await build(post).postSigned("/v1/bulkRegister", request); - expect(sentConfig(post).headers).toEqual({ - "Content-Type": "application/json", - Authorization: `Bearer ${TOKEN}`, - }); + expect(sentConfig(post).headers).toEqual(protectedHeaders); }); it("wraps a signed call in the {request,signature,certificate} envelope", async () => { @@ -85,26 +110,24 @@ describe("EimsClientService transport", () => { expect(sentBody(post)).toEqual({ Irn: "irn-1" }); }); - it("re-signs and re-authenticates through the one 401 retry", async () => { + it("re-authenticates a raw verify call through the one 401 retry without changing its body", 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" }); + await build(post).postBearer("/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", - }); + expect(sentBody(post, 1)).toEqual({ irn: "irn-1" }); }); it("never leaks the api key, bearer token or client secret into a thrown failure", async () => { + const logError = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined); const post = jest.fn().mockReturnValue( throwError(() => - // The live shape of an unsigned /v1/verify rejection, as observed on 2026-09-01. + // A gateway rejection may echo request data; redaction must remove it before logging. axiosErr(400, { message: "GATEWAY ERROR", code: "4001", @@ -120,7 +143,7 @@ describe("EimsClientService transport", () => { ); const error: Error = await build(post) - .postSigned("/v1/verify", { irn: "irn-1" }) + .postBearer("/v1/verify", { irn: "irn-1" }) .then(() => { throw new Error("expected the call to reject"); }) @@ -134,7 +157,12 @@ describe("EimsClientService transport", () => { expect(serialized).not.toContain(API_KEY); expect(serialized).not.toContain(CLIENT_SECRET); expect(serialized).not.toContain(TOKEN); + const serializedLogs = JSON.stringify(logError.mock.calls); + expect(serializedLogs).not.toContain(API_KEY); + expect(serializedLogs).not.toContain(CLIENT_SECRET); + expect(serializedLogs).not.toContain(TOKEN); // The gateway's own reporting still survives redaction. expect(error.message).toContain("4001"); + logError.mockRestore(); }); }); 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 1870814c5..4f2455c85 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 @@ -8,7 +8,7 @@ import { EimsSignerService, toSignedBody } from "./eims-signer.service"; import { toEimsApiException } from "./eims.errors"; /** - * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * Foundation for EIMS's authenticated endpoints (`/v1/register`, `/v1/verify`, …). * * Login is not routed through here: `/auth/login` carries no bearer token and lives in * `EimsAuthService`. @@ -29,7 +29,8 @@ export class EimsClientService { } /** - * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * Sign `request`, POST it to `path` with the shared protected-endpoint headers, and return the + * parsed response. * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { @@ -37,17 +38,8 @@ export class EimsClientService { } /** - * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. - * - * 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. + * POST `request` verbatim with the shared protected-endpoint headers, but **not** wrapped in a + * signed envelope. This is the wire contract for verify, cancel and receipt calls. */ async postBearer(path: string, request: TRequest): Promise { return this.send(path, request, false, false); @@ -66,7 +58,11 @@ export class EimsClientService { try { const res = await firstValueFrom( this.http.post(`${cfg.baseUrl}${path}`, body, { - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + apikey: cfg.apiKey, + }, timeout: cfg.httpTimeoutMs, }), ); 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 8f8a94a7c..34e654e3b 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,39 +759,37 @@ describe("EimsInvoiceRegistrationService staff alerting", () => { }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { - it("verifies the stored IRN over the signed transport", async () => { + it("verifies the stored IRN as an unchanged raw body", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn(); - const postSigned = jest.fn().mockResolvedValue(verifyResponse()); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn(); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); - // 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(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).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 postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); await expect( - build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + build(db, jest.fn(), config(), postBearer).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 postSigned = jest.fn(); + const postBearer = jest.fn(); await expect( - build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/no EIMS IRN to verify/); - expect(postSigned).not.toHaveBeenCalled(); + expect(postBearer).not.toHaveBeenCalled(); }); it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => { @@ -803,12 +801,12 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { // 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 + const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400)); await expect( - build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/4001/); expect(db.invoices.get(INVOICE_ID)).toEqual(before); @@ -835,15 +833,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("records a confirmed IRN, resumes the chain and clears the block", async () => { const db = blocked(); - const postSigned = jest.fn().mockResolvedValue(verifyResponse()); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); - const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( INVOICE_ID, { irn: IRN }, ); // The IRN is confirmed at the gateway before it is ever written. - expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); expect(db.state).toMatchObject({ previousIrn: IRN, @@ -854,12 +852,12 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { const db = blocked(); - const postSigned = jest + const postBearer = jest .fn() .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); await expect( - build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/answered the lookup for IRN/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -875,14 +873,14 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); - const postSigned = jest.fn().mockResolvedValue( + const postBearer = jest.fn().mockResolvedValue( verifyResponse({ DocumentDetails: { Type: "INV", DocumentNumber: "99999" }, }), ); await expect( - build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/not 5/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -898,10 +896,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway does not acknowledge at all", async () => { const db = blocked(); - const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); await expect( - build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/returned no Irn/); expect(db.state!.blockedReason).toBe("never acknowledged"); }); 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 65221d48c..246914e1c 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,13 +214,11 @@ 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. * - * 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. + * Raw, via `postBearer`: verification accepts exactly `{"irn":"…"}` and relies on the shared + * transport for the bearer token and API-key header. It must not be signed or wrapped. */ private async queryVerify(irn: string): Promise { - const response = await this.client.postSigned( + const response = await this.client.postBearer( "/v1/verify", { irn }, ); diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index 55a38480b..99af6ef30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -191,6 +191,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => { const db = new FakeDb([invoiceRow()]); + const invoiceBefore = { ...db.invoices.get(INVOICE_ID)! }; const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406)); @@ -200,6 +201,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; expect(receipt.status).toBe(EimsReceiptStatus.Failed); + expect(db.invoices.get(INVOICE_ID)).toEqual(invoiceBefore); }); it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {