Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts
Hagernesh 2644d5e52d feat(eims): add invoice mapper and signed EIMS transport
Map EDR invoices onto the MoR EIMS /v1/register document and add the
cryptographic transport needed to talk to core.mor.gov.et.

Mapper: DTOs mirror the supplied Postman collection section by section.
Tax is resolved per line via a caller-supplied resolver and throws when
unresolved -- the app models no tax at all (invoice.taxAmount is always 0,
invoice_lines and the rate catalogue carry no fiscal columns), so a
zero-rated default would assert a tax position the codebase cannot support.
Seller identity, document number, counters and previous IRN are passed in
explicitly; the mapper stays pure.

Transport: config, credential loading, RSA-SHA512 signing and /auth/login
with an in-memory token cache. Signing reproduces the process that produced
a working live token -- compact JSON of the inner request only, exact UTF-8
bytes, base64 signature, and base64 of the certificate file's exact bytes
with no parsing or re-encoding. Concurrent callers share one login via an
in-flight promise. Refresh is deliberately unimplemented: the collection
shows an unsigned refresh body but also ships unsigned examples of calls
that do require signing, so an expired token re-logs in instead.

Errors normalise to EimsApiException carrying only the gateway's own error
fields; secrets, signature, certificate and tokens never reach logs.
Key and certificate file patterns are gitignored.

Nothing calls EIMS automatically and no invoice entity, migration or UI is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:08:40 +00:00

119 lines
4.8 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createVerify, generateKeyPairSync } from "node:crypto";
import { ConfigService } from "@nestjs/config";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
/**
* Test-only key material: generated per run, never a production key. The "certificate" fixture is
* an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not
* that it is a valid X.509 chain.
*/
const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n";
let dir: string;
let keyPath: string;
let certPath: string;
let publicKeyPem: string;
let signer: EimsSignerService;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "eims-signer-"));
keyPath = join(dir, "private_key.key");
certPath = join(dir, "certificate.pem.txt");
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" }));
writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8");
publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
const config = {
get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }),
} as unknown as ConfigService;
signer = new EimsSignerService(new EimsCredentialsProvider(config));
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" });
const verify = (payload: string, signature: string): boolean =>
createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64");
describe("EimsSignerService", () => {
it("produces a signature that verifies against the matching public key", () => {
const signed = signer.signRequest(login());
expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true);
});
it("fails verification when a single request field changes", () => {
const signed = signer.signRequest(login());
const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" });
expect(verify(tampered, signed.signature)).toBe(false);
});
it("emits a 256-byte signature for an RSA-2048 key", () => {
const signed = signer.signRequest(login());
expect(Buffer.from(signed.signature, "base64")).toHaveLength(256);
});
it("sends the certificate as base64 of the file's exact bytes", () => {
const signed = signer.signRequest(login());
expect(signed.certificate).toBe(readFileSync(certPath).toString("base64"));
expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true);
});
it("signs the inner request only, and the wire body carries those exact bytes", () => {
const signed = signer.signRequest(login());
const body = toSignedBody(signed);
// The signed string appears verbatim inside the transmitted envelope.
expect(body).toContain(`"request":${JSON.stringify(signed.request)}`);
// Compact, never pretty-printed.
expect(body).not.toMatch(/\n/);
expect(JSON.parse(body)).toEqual({
request: login(),
signature: signed.signature,
certificate: signed.certificate,
});
});
it("does not mutate the request object", () => {
const request = login();
const signed = signer.signRequest(request);
expect(signed.request).toBe(request);
expect(request).toEqual(login());
});
it("reuses the loaded key and certificate across calls", () => {
const first = signer.signRequest(login());
const second = signer.signRequest(login());
// PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature.
expect(second.signature).toBe(first.signature);
expect(second.certificate).toBe(first.certificate);
});
});
describe("EimsCredentialsProvider", () => {
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => {
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
});
it("fails clearly when the key file is missing", () => {
expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow(
/could not be read or parsed/,
);
});
it("fails clearly when the certificate file is empty", () => {
const emptyPath = join(dir, "empty.txt");
writeFileSync(emptyPath, "");
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
});
});