mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 08:18:20 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user