fix(eims): sign the /v1/verify request body

This commit is contained in:
Hagernesh
2026-09-01 03:41:28 +00:00
parent dd9f597e01
commit fe3d398757
4 changed files with 202 additions and 32 deletions

View File

@@ -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: <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", () => {
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");
});
});

View File

@@ -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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
return this.send<TRequest, TResponse>(path, request, false, false);

View File

@@ -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/);

View File

@@ -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<EimsVerifyResponse> {
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
const response = await this.client.postSigned<EimsVerifyRequest, EimsVerifyResponse>(
"/v1/verify",
{ irn },
);