diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 0df076f57..26641ee70 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -135,7 +135,9 @@ EIMS_CLIENT_ID= EIMS_CLIENT_SECRET= EIMS_API_KEY= EIMS_TIN= -# MoR-issued source-system identifiers (used once invoice registration lands) +# Source-system identity comes from the access token's systemNumber/systemType claims. +# Setting these turns them into expected-value checks: a mismatch against the token fails +# fast rather than one side silently winning. Leave empty to take the gateway's word. EIMS_SYSTEM_NUMBER= EIMS_SYSTEM_TYPE= # Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 4e8684a5b..0cadb55fb 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -16,7 +16,14 @@ export interface EimsConfig { clientSecret: string; apiKey: string; tin: string; - /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + /** + * Optional *expectations* for the source-system identity, not inputs. + * + * The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials + * that authenticated, and those are what registration uses. When these are set they are compared + * against the token and a mismatch fails fast — neither side silently wins. Leave them empty to + * take whatever the gateway says. + */ systemNumber: string; systemType: string; /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 75a702e94..1bed1fe29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,57 +2,18 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; -import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; +import { EimsConfig } from "../../config/eims.config"; +import { eimsConfig, eimsToken } from "./eims-test-fixtures"; import { EimsAuthService } from "./eims-auth.service"; import { EimsSignerService } from "./eims-signer.service"; const CLIENT_SECRET = "super-secret-value"; const API_KEY = "super-secret-apikey"; -const cfg = (over: Partial = {}): EimsConfig => ({ - enabled: true, - baseUrl: "https://core.mor.gov.et", - clientId: "cid", - clientSecret: CLIENT_SECRET, - apiKey: API_KEY, - tin: "0000034558", - systemNumber: "B0360154BA", - systemType: "SYS", - privateKeyPath: "/dev/null", - certificatePath: "/dev/null", - httpTimeoutMs: 30_000, - tokenSkewMs: 45_000, - invoice: eimsInvoiceConfig(), - ...over, -}); +const cfg = (over: Partial = {}): EimsConfig => eimsConfig(over); -/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ -export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ - sellerLegalName: "Ethio-Djibouti Railway S.C.", - sellerVatNumber: "0000000000", - sellerPhone: "0911223344", - sellerEmail: "finance@example.et", - sellerRegion: "13", - sellerWereda: "574", - sellerCity: null, - sellerSubCity: null, - sellerHouseNumber: null, - sellerLocality: null, - taxCode: "VAT15", - taxRatePercent: 15, - exciseTaxValue: 0, - incomeWithholdValue: 0, - transactionWithholdValue: 0, - transactionType: "B2B", - natureOfSupplies: "Service", - paymentMode: "CASH", - paymentTerm: "IMMIDIATE", - unitDefault: "PCS", - buyerCountryCode: null, - cashierName: null, - salesPersonName: null, - ...over, -}); +const TOKEN_1 = eimsToken({ jti: "one" }); +const TOKEN_2 = eimsToken({ jti: "two" }); const loginBody = (accessToken: string, expiresIn = 3600) => ({ data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn }, @@ -82,7 +43,7 @@ const axiosErr = (status: number, data: unknown) => describe("EimsAuthService.getValidAccessToken", () => { it("posts the signed login envelope to /auth/login with no Authorization header", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); await build(post).getValidAccessToken(); @@ -101,38 +62,38 @@ describe("EimsAuthService.getValidAccessToken", () => { }); it("returns the access token from data.accessToken", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); - await expect(build(post).getValidAccessToken()).resolves.toBe("token-1"); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1); }); it("reuses a cached token instead of logging in again", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); await auth.getValidAccessToken(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); expect(post).toHaveBeenCalledTimes(1); }); it("re-authenticates a skew-window before the token actually expires", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); const auth = build(post); const start = Date.now(); const clock = jest.spyOn(Date, "now"); try { clock.mockReturnValue(start); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); clock.mockReturnValue(start + 50_000); // inside the window: still cached - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); expect(post).toHaveBeenCalledTimes(1); clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry - await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); } finally { clock.mockRestore(); @@ -142,24 +103,38 @@ describe("EimsAuthService.getValidAccessToken", () => { it("logs in again after invalidate()", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1") })) - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1) })) + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); const auth = build(post); await auth.getValidAccessToken(); auth.invalidate(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); }); it("performs exactly one login for many concurrent callers", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken())); expect(post).toHaveBeenCalledTimes(1); - expect(new Set(tokens)).toEqual(new Set(["token-1"])); + expect(new Set(tokens)).toEqual(new Set([TOKEN_1])); + }); + + it("does not put the access token in its own log line", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const logged: string[] = []; + const auth = build(post); + jest + .spyOn(auth["logger"], "log") + .mockImplementation((message: unknown) => void logged.push(String(message))); + + await auth.getValidAccessToken(); + + expect(logged.join("\n")).not.toContain(TOKEN_1); + expect(logged.join("\n")).toContain("B0360154BA"); }); it("refuses to call the gateway when EIMS is disabled", async () => { @@ -217,3 +192,65 @@ describe("EimsAuthService.getValidAccessToken", () => { await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/); }); }); + +describe("EimsAuthService.getSessionContext", () => { + it("takes the source system from the token's claims", async () => { + const post = jest + .fn() + .mockReturnValue( + of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }), + ); + + // Env deliberately left empty: with nothing to check against, the token is simply believed. + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" }); + }); + + it("serves the session from the cached login rather than re-authenticating", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const auth = build(post); + + await auth.getSessionContext(); + await expect(auth.getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + expect(post).toHaveBeenCalledTimes(1); + }); + + it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => { + const post = jest + .fn() + .mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) })); + + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).rejects.toThrow(new RegExp(`no ${claim} claim`)); + }); + + it("rejects an access token that is not a decodable JWT", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") })); + + await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/); + }); + + it.each([ + ["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/], + ["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/], + ])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) })); + + // cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations. + await expect(build(post).getSessionContext()).rejects.toThrow(pattern); + }); + + it("accepts a configured value that matches the token", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + + await expect(build(post).getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts index 99af70c57..9e53723d0 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -11,8 +11,43 @@ interface TokenCache { accessToken: string; /** Epoch ms, already reduced by the configured skew. */ expiresAt: number; + session: EimsSessionContext; } +/** + * Source-system identity, taken from the access token MoR issues us. + * + * The gateway stamps `systemNumber` and `systemType` into the token for the credentials that + * authenticated, which makes the token the authority on them — not our environment file. Anything + * we configured locally can only ever disagree with what MoR believes. + */ +export interface EimsSessionContext { + systemNumber: string; + systemType: string; +} + +/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */ +function decodeTokenClaims(accessToken: string): Record { + const payload = accessToken.split(".")[1]; + if (!payload) { + throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)"); + } + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch (err) { + // The token itself is never included — only that its payload would not parse. + throw new EimsApiException( + "UNKNOWN", + `EIMS access token payload could not be decoded: ${(err as Error).message}`, + ); + } +} + +const claimString = (claims: Record, name: string): string => { + const value = claims[name]; + return typeof value === "string" ? value.trim() : ""; +}; + /** Used when the gateway omits `expiresIn`; the observed value is 3600. */ const FALLBACK_EXPIRES_IN_SECONDS = 3600; @@ -57,11 +92,64 @@ export class EimsAuthService { } } + /** + * The source-system identity MoR issued this session, refreshing the login if needed. + * + * This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both + * into the access token for the authenticating credentials, so a local env value could only ever + * disagree with it. + */ + async getSessionContext(): Promise { + await this.getValidAccessToken(); + return this.cache!.session; + } + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ invalidate(): void { this.cache = null; } + /** + * Read the source-system claims out of the token, and cross-check anything configured locally. + * + * `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they + * are compared and a mismatch fails immediately rather than one silently winning. Registering + * under the wrong source system is not something to discover from a rejected invoice. + */ + private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext { + const claims = decodeTokenClaims(accessToken); + const systemNumber = claimString(claims, "systemNumber"); + const systemType = claimString(claims, "systemType"); + + const missing = [ + !systemNumber && "systemNumber", + !systemType && "systemType", + ].filter(Boolean); + if (missing.length > 0) { + throw new EimsApiException( + "UNKNOWN", + `EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`, + ); + } + + const mismatches = [ + cfg.systemNumber && cfg.systemNumber !== systemNumber + ? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}` + : null, + cfg.systemType && cfg.systemType !== systemType + ? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}` + : null, + ].filter(Boolean); + if (mismatches.length > 0) { + throw new EimsConfigException( + `EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` + + "Correct the environment or the credentials — neither value is assumed to win.", + ); + } + + return { systemNumber, systemType }; + } + private async login(): Promise { const cfg = this.cfg; if (!cfg.enabled) { @@ -106,11 +194,19 @@ export class EimsAuthService { // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, // so that is one extra call an hour. + // Reject the session before caching it: a token we cannot identify a source system from is + // useless for registration, and a configured expectation that disagrees is a deployment fault. + const session = this.readSessionContext(accessToken, cfg); + this.cache = { accessToken, expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + session, }; - this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`); + this.logger.log( + `EIMS login succeeded; token cached for ~${expiresIn}s ` + + `(system ${session.systemNumber}, type ${session.systemType})`, + ); return accessToken; } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 1a68f2ce4..20ccfdfba 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { EimsConfig } from "../../config/eims.config"; +import { EimsSessionContext } from "./eims-auth.service"; import { EimsMapperContext, EimsMapperLine, @@ -21,10 +22,10 @@ interface RequiredSpec { value: string | number | null | undefined; } -const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ +// `systemNumber` / `systemType` are absent by design: they come from the access token, which is +// MoR's own statement of who we are. See EimsAuthService.getSessionContext. +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [ { env: "EIMS_TIN", value: tin }, - { env: "EIMS_SYSTEM_NUMBER", value: systemNumber }, - { env: "EIMS_SYSTEM_TYPE", value: systemType }, { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, @@ -44,7 +45,7 @@ const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: str /** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */ export function assertEimsInvoiceConfig(config: EimsConfig): void { - const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType) + const missing = REQUIRED(config.invoice, config.tin) .filter(({ value }) => value === null || value === undefined || value === "") .map(({ env }) => env); @@ -80,6 +81,8 @@ export interface EimsContextInput { documentNumber: string; invoiceCounter: number; previousIrn: string | null; + /** Source-system identity from the access token, never from configuration. */ + session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; } @@ -92,8 +95,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E const exciseTaxValue = invoice.exciseTaxValue ?? 0; return { - systemNumber: config.systemNumber, - systemType: config.systemType, + systemNumber: input.session.systemNumber, + systemType: input.session.systemType, documentNumber: input.documentNumber, invoiceCounter: input.invoiceCounter, previousIrn: input.previousIrn, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 362c754cb..1035c2b33 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -5,7 +5,8 @@ import { DataSource } from "typeorm"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; -import { eimsInvoiceConfig } from "./eims-auth.service.spec"; +import { eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; @@ -138,25 +139,35 @@ class FakeDb { } } +/** The source system comes from the access token, so the service is handed a session, not config. */ +const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" }; + const build = ( db: FakeDb, postSigned: jest.Mock, cfg: EimsConfig = config(), postBearer: jest.Mock = jest.fn(), + getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, + { getSessionContext } as unknown as EimsAuthService, ); /** Document number the fixtures register under; `/v1/verify` must echo it back. */ const DOCUMENT_NUMBER = "INV-20260807-00042"; /** - * `/v1/verify` success. The response spells the reference `Irn` while the request uses `irn`, and - * the collection's own fixture uses a *different* example value on each side — so nothing here - * assumes the two match. + * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase + * `irn`. + * + * The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection + * pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of + * the mock rather than gateway behaviour; asserting against that inconsistency would encode the + * mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for, + * and these fixtures exercise that honestly. */ const verifyResponse = (over: Record = {}) => ({ statusCode: 200, @@ -213,6 +224,43 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); + it("takes SourceSystem from the token session, not from configuration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + // Config disagrees on purpose: only the session may reach the wire. + const cfg = config(); + (cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY"; + (cfg as { systemType: string }).systemType = "MAN"; + + await build( + db, + postSigned, + cfg, + jest.fn(), + jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }), + ).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN"); + expect(request.SourceSystem.SystemType).toBe("POS"); + }); + + it("does not consume a counter when authentication fails", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed")); + + await expect( + build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toThrow(/login failed/); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + }); + it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { const db = new FakeDb([ invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), @@ -377,17 +425,6 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { expect(result.body).toMatchObject({ Irn: IRN }); }); - it("accepts a response whose Irn differs from the one sent", async () => { - // The supplied collection's own fixture does exactly this; equality would assert a property - // of the mock, not of the gateway. - const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); - - await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), - ).resolves.toMatchObject({ body: { Irn: "a-different-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: " " } }); @@ -436,6 +473,27 @@ 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 + .fn() + .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); + + await expect( + 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({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue( diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 44b92cd9b..4ff9ccfb8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -17,6 +17,7 @@ import { EimsMapperLine, toEimsInvoice, } from "../billing/eims-invoice.mapper"; +import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -70,6 +71,7 @@ export class EimsInvoiceRegistrationService { @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, private readonly client: EimsClientService, + private readonly auth: EimsAuthService, ) {} private get cfg(): EimsConfig { @@ -84,7 +86,11 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); - const reservation = await this.reserve(invoiceId, cfg.systemNumber); + // Authenticate before reserving: the source system comes from the token, and the state row is + // keyed by it. A login failure here costs nothing — no counter has been consumed yet. + const session = await this.auth.getSessionContext(); + + const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. @@ -96,6 +102,7 @@ export class EimsInvoiceRegistrationService { documentNumber: invoice.invoiceNumber, invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, + session, }), ); @@ -164,18 +171,33 @@ export class EimsInvoiceRegistrationService { } /** - * Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice. + * Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this + * IRN is the one it holds, and that it belongs to this invoice. * - * The check is on `DocumentDetails.DocumentNumber`, which registration set from our own - * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + * The document-number check is against `DocumentDetails.DocumentNumber`, which registration set + * from our own `invoiceNumber` — the only field tying an IRN back to a row in this database. + * + * Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and + * chains every later document to a stranger's reference, so both checks are refusals rather + * than warnings. */ private async assertIrnBelongsToInvoice( irn: string, expectedDocumentNumber: string, ): Promise { const response = await this.queryVerify(irn); + const returnedIrn = response.body?.Irn?.trim(); const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + if (returnedIrn !== irn) { + throw new ConflictException({ + code: "EIMS_RESOLVE_IRN_MISMATCH", + message: + `EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` + + "Refusing to record it — recheck the IRN in the MoR portal.", + }); + } + if (documentNumber !== expectedDocumentNumber) { throw new ConflictException({ code: "EIMS_RESOLVE_DOCUMENT_MISMATCH", @@ -216,8 +238,11 @@ export class EimsInvoiceRegistrationService { await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); } + // Same source of truth as registration: the state row is keyed by the token's system number. + const session = await this.auth.getSessionContext(); + await this.dataSource.transaction(async (manager) => { - const state = await this.lockSystemState(manager, this.cfg.systemNumber); + const state = await this.lockSystemState(manager, session.systemNumber); if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { throw new ConflictException({ code: "EIMS_RESOLVE_WRONG_INVOICE", diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts new file mode 100644 index 000000000..f411c8b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -0,0 +1,75 @@ +import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; + +/** + * Fixtures shared by the EIMS specs. + * + * Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that + * file's `describe` blocks inside every importing suite, so the same tests run — and report — + * twice. + */ + +export const EIMS_SYSTEM_NUMBER = "B0360154BA"; +export const EIMS_SYSTEM_TYPE = "SYS"; + +export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ + sellerLegalName: "Ethio-Djibouti Railway S.C.", + sellerVatNumber: "0000000000", + sellerPhone: "0911223344", + sellerEmail: "finance@example.et", + sellerRegion: "13", + sellerWereda: "574", + sellerCity: null, + sellerSubCity: null, + sellerHouseNumber: null, + sellerLocality: null, + taxCode: "VAT15", + taxRatePercent: 15, + exciseTaxValue: 0, + incomeWithholdValue: 0, + transactionWithholdValue: 0, + transactionType: "B2B", + natureOfSupplies: "Service", + paymentMode: "CASH", + paymentTerm: "IMMIDIATE", + unitDefault: "PCS", + buyerCountryCode: null, + cashierName: null, + salesPersonName: null, + ...over, +}); + +export const eimsConfig = (over: Partial = {}): EimsConfig => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "super-secret-value", + apiKey: "super-secret-apikey", + tin: "0000034558", + systemNumber: EIMS_SYSTEM_NUMBER, + systemType: EIMS_SYSTEM_TYPE, + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** + * A structurally real access token. MoR stamps the source-system identity into the JWT payload and + * `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is + * never verified locally — it is MoR's, signed with MoR's key. + * + * Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo). + */ +export const eimsToken = (claims: Record = {}): string => { + const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims }; + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) delete (payload as Record)[key]; + } + return [ + "eyJhbGciOiJSUzI1NiJ9", + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "signature", + ].join("."); +};