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"; 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", () => { const protectedHeaders = { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, apikey: API_KEY, }; 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("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(protectedHeaders); }); 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-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).postBearer("/v1/verify", { irn: "irn-1" }); expect(post).toHaveBeenCalledTimes(2); expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`); 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(() => // A gateway rejection may echo request data; redaction must remove it before logging. 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) .postBearer("/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); 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(); }); });