Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts
2026-09-02 07:17:01 +03:00

169 lines
6.4 KiB
TypeScript

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: <T>(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();
});
});