mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
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>
119 lines
4.8 KiB
TypeScript
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/);
|
|
});
|
|
});
|