feat(eims): take the source system from the access token

MoR stamps systemNumber and systemType into the access token it issues for
the authenticating credentials, which makes the token the authority on them.
Registration now reads both from there instead of from configuration, so the
SourceSystem block cannot drift from what the gateway believes we are.

EimsAuthService decodes the token payload after login, requires both claims
to be non-empty, and exposes them through getSessionContext(). The token is
decoded but never verified -- it is MoR's, signed with MoR's key -- and is
kept out of the log line, which names only the system it identified.

EIMS_SYSTEM_NUMBER and EIMS_SYSTEM_TYPE become optional expectations rather
than inputs: when set they are compared against the claims and a mismatch
fails fast, so neither side silently wins. Neither is required to register
any more.

Registration and manual resolution both resolve the session before touching
the state row, which is keyed by the system number: a login failure now
costs nothing because no counter has been reserved yet.

Test fixtures move to eims-test-fixtures.ts. They previously lived in
eims-auth.service.spec.ts, which made jest execute that suite again inside
every importing spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-07 13:50:05 +00:00
parent eadecf3fcf
commit 2e7ef40d9e
8 changed files with 391 additions and 88 deletions

View File

@@ -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<void> {
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",