mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 09:43:40 +00:00
api key fix
This commit is contained in:
@@ -26,6 +26,20 @@ const withEnv = (vars: Record<string, string | undefined>, 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" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
@@ -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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||
@@ -66,7 +58,11 @@ export class EimsClientService {
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<TResponse>(`${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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<EimsVerifyResponse> {
|
||||
const response = await this.client.postSigned<EimsVerifyRequest, EimsVerifyResponse>(
|
||||
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
|
||||
"/v1/verify",
|
||||
{ irn },
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user