From 82d678e8fe0675d96d0528eb4ed69bee32535a5d Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Wed, 11 Nov 2026 08:49:23 +0100 Subject: [PATCH] feat: change-request round in DECADE-X onboarding The TSP spec renames ON_HOLD to CHANGES_REQUESTED and adds a changeRequest to the onboarding request. The stand-in now serves it: a request asked for changes is corrected like a draft and submitted again (back to UNDER_REVIEW), and the automatic review asks every request for changes once before it approves (dx-onboarding.review.request-changes). The Membership Hub submits a CHANGES_REQUESTED request again when it polls its status. Co-Authored-By: Claude Fable 5.2 --- charts/cx-ve/values.yaml | 3 +- charts/dx-onboarding-api/values.yaml | 7 ++ dx-onboarding-api/README.md | 34 +++++- dx-onboarding-api/dataspace-api-docs.yaml | 34 +++++- dx-onboarding-api/onboarding.md | 45 ++++++- .../adapter/in/dto/OnboardingRequestView.java | 10 +- .../application/ApprovalService.java | 9 +- .../application/AutomaticReview.java | 99 ++++++++++++++-- .../DefaultOnboardingRequestService.java | 61 ++++++++-- .../application/OnboardingRequestService.java | 19 +-- .../dxonboarding/config/ReviewProperties.java | 20 +++- .../dxonboarding/domain/ProblemCode.java | 2 +- .../model/onboarding/ChangeRequest.java | 44 +++++++ .../model/onboarding/OnboardingRequest.java | 102 ++++++++++++---- .../model/onboarding/OnboardingStatus.java | 16 ++- .../src/main/resources/application.yaml | 11 +- .../web/OnboardingRequestControllerTest.java | 55 +++++++++ .../adapter/in/web/SpecConformanceTest.java | 29 ++++- .../application/ApprovalServiceTest.java | 20 ++++ .../application/AutomaticReviewTest.java | 112 +++++++++++++++--- .../DefaultOnboardingRequestServiceTest.java | 103 +++++++++++++++- .../onboarding/OnboardingRequestTest.java | 54 +++++++++ membership-hub/README.md | 3 +- .../onboarding/decadex/DecadeXOnboarding.java | 46 ++++++- .../decadex/DecadeXOnboardingTest.java | 29 +++++ 25 files changed, 855 insertions(+), 112 deletions(-) create mode 100644 dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/ChangeRequest.java diff --git a/charts/cx-ve/values.yaml b/charts/cx-ve/values.yaml index 8b0e72d..2f8454d 100644 --- a/charts/cx-ve/values.yaml +++ b/charts/cx-ve/values.yaml @@ -155,7 +155,8 @@ core-platform-distribution: # ---- DECADE-X onboarding API ------------------------------------------------------------------- # The TSP onboarding intake. There is no TSP operator yet: requests are approved automatically — -# those of participants hosted here, and for now those of external ones too +# those of participants hosted here, and for now those of external ones too — after being asked +# for changes once, which the applicant answers by submitting the request again # (dx-onboarding-api.config.dx-onboarding.review). On approval it registers the participant as a # credential holder and has the platform's issuer offer it the DX_LegalEntity_VC and DX_Site_VC (see # decadex-profile below). diff --git a/charts/dx-onboarding-api/values.yaml b/charts/dx-onboarding-api/values.yaml index d6a76e8..a26af2f 100644 --- a/charts/dx-onboarding-api/values.yaml +++ b/charts/dx-onboarding-api/values.yaml @@ -105,9 +105,16 @@ config: # No TSP operator in the VE yet: participants hosted here (DID under hosted-did-prefix, the # hub's participant.did.template — host-derived, the install scripts override it) are approved # automatically; external ones too, for now. Switch external off to have them wait in SUBMITTED. + # Before it approves, the automatic review asks for changes once (request-changes): the request + # becomes CHANGES_REQUESTED and is approved once the applicant submitted it again. The hub does + # that for every member it registers, hosted here or not; a participant calling this API on its + # own has to do it itself. review: hosted-did-prefix: "did:web:identity.cxve.localhost:" auto-approve: hosted: true external: true + request-changes: + hosted: true + external: true delay: 0s diff --git a/dx-onboarding-api/README.md b/dx-onboarding-api/README.md index be4b9f4..74535eb 100644 --- a/dx-onboarding-api/README.md +++ b/dx-onboarding-api/README.md @@ -2,7 +2,9 @@ The VE's stand-in for the DECADE-X TSP's onboarding intake, implementing [`dataspace-api-docs.yaml`](dataspace-api-docs.yaml): an applicant creates a **draft**, adds its -documents one by one, and **submits** it for review. Once the TSP approves the participant, this +documents one by one, and **submits** it for review. The review may hand it back +(`CHANGES_REQUESTED`): the applicant then corrects it like a draft and submits it again. Once the +TSP approves the participant, this service registers it as a credential holder with the IssuerService and has the issuer offer it the `DX_LegalEntity_VC` and `DX_Site_VC` — nothing else: the participant's EDC resources (its participant context on IdentityHub, its control plane, its DID) exist before it onboards. @@ -53,13 +55,25 @@ sequenceDiagram CSP-xTSP: PUT /{id} or any document change TSP-->>CSP: 422 request already submitted OP->>TSP: review, may edit the request and its documents - OP->>TSP: approve or reject + OP->>TSP: approve, reject or request changes end loop Follow-up CSP->>GW: GET /{id} GW->>TSP: relay - TSP-->>CSP: 200 { status: UNDER_REVIEW / ON_HOLD / APPROVED / REJECTED } + TSP-->>CSP: 200 { status: UNDER_REVIEW / CHANGES_REQUESTED / APPROVED / REJECTED } + end + + rect rgba(150,110,220,0.12) + Note over CSP,OP: Only when CHANGES_REQUESTED: the request is the CSP's again + CSP->>GW: GET /{id} + GW->>TSP: relay + TSP-->>CSP: 200 { status: CHANGES_REQUESTED, changeRequest: { comment, items[] } } + CSP->>GW: PUT /{id} and document changes, as on a draft (no DELETE /{id}) + GW->>TSP: relay + CSP->>GW: POST /{id}/submit (If-Match: version) + GW->>TSP: relay + TSP-->>CSP: 200 { status: UNDER_REVIEW, changeRequest.resubmittedAt } end ``` @@ -72,8 +86,18 @@ the VE's OSP IdP. and an `http-pull` transfer. The caller's identity — which the spec leaves unsettled — is taken from the `X-Connector-Id` header (`dx-onboarding.connector-id-header`), UNVERIFIED; it is the participant's DID. -- **No operator.** A submitted request is approved automatically (`dx-onboarding.review`), or - waits in `SUBMITTED` for a review that does not exist. +- **No operator.** A submitted request is reviewed automatically (`dx-onboarding.review`), or + waits in `SUBMITTED` for a review that does not exist. The automatic review takes the request + on (`UNDER_REVIEW`) and approves it. +- **Every request is asked for changes once.** Before it approves, the automatic review hands the + request back (`CHANGES_REQUESTED`), with a `changeRequest` that asks for nothing but to submit + the request again; it approves once that happened. This way every participant goes through the + round a real review may ask of it. `dx-onboarding.review.request-changes.hosted` and + `.external` switch it off. The Membership Hub submits the requests it filed again by itself. +- **Replays while changes are requested.** A submit sent again is answered with the request as + it is. Once changes are requested, a submit is the answer to them — unless it carries the + `Idempotency-Key` of the first submit and an outdated version: that is the first submit sent + again, and changes nothing. - **Hosted participants keep an out-of-band DECADE-X-ID.** The TSP assigns every participant its DECADE-X-ID on approval. A participant this VE hosts (a connector identity under `dx-onboarding.review.hosted-did-prefix` — in practice the verification participant) is diff --git a/dx-onboarding-api/dataspace-api-docs.yaml b/dx-onboarding-api/dataspace-api-docs.yaml index b97cf9a..06a0c37 100644 --- a/dx-onboarding-api/dataspace-api-docs.yaml +++ b/dx-onboarding-api/dataspace-api-docs.yaml @@ -919,6 +919,36 @@ components: updatingDate: type: string format: date-time + ChangeRequest: + type: object + properties: + requestedAt: + type: string + format: date-time + comment: + type: string + items: + type: array + items: + $ref: '#/components/schemas/ChangeRequestItemDto' + resubmittedAt: + type: string + format: date-time + ChangeRequestItemDto: + type: object + properties: + field: + type: string + documentType: + type: string + enum: + - gtc + - uca + - registration-extract + - power-of-attorney + - other + note: + type: string Consent: type: object properties: @@ -958,7 +988,7 @@ components: - DRAFT - SUBMITTED - UNDER_REVIEW - - ON_HOLD + - CHANGES_REQUESTED - APPROVAL_IN_PROGRESS - APPROVAL_FAILED - APPROVED @@ -998,6 +1028,8 @@ components: description: The request's `other` documents (also in `GET /{requestId}/documents`). decision: $ref: '#/components/schemas/Decision' + changeRequest: + $ref: '#/components/schemas/ChangeRequest' description: An onboarding request with its status. `decision` is null until a decision is taken. DataspaceOnboardingRequestWriteDto: type: object diff --git a/dx-onboarding-api/onboarding.md b/dx-onboarding-api/onboarding.md index e5ea317..8b74fa4 100644 --- a/dx-onboarding-api/onboarding.md +++ b/dx-onboarding-api/onboarding.md @@ -7,11 +7,34 @@ While the request is a draft, you can still correct it (replace data/documents, remove documents, or delete the draft). 2. After submission, the request is read-only for you - Once a request is submitted, it belongs to the review. You can no longer change it; only the TSP operator can modify the request. If something needs correcting, only the operator can do it. + Once a request is submitted, it belongs to the review. You can no longer change it; only the TSP operator can modify the request. If something needs correcting, the operator either does it or asks you to (see 3). -You can still follow its progress with GET /{requestId} (Statuses: SUBMITTED, UNDER_REVIEW, ON_HOLD, APPROVAL_IN_PROGRESS, APPROVED, REJECTED) and download its documents. Nothing is provisioned until an operator approves the request. +You can still follow its progress with GET /{requestId} (Statuses: SUBMITTED, UNDER_REVIEW, CHANGES_REQUESTED, APPROVAL_IN_PROGRESS, APPROVED, REJECTED) and download its documents. Nothing is provisioned until an operator approves the request. -3. Things to know +3. When the review requests changes + The operator can hand the request back to you: its status becomes CHANGES_REQUESTED, and its changeRequest says what to change (a comment, and the fields and documents concerned in items). +- The request is yours again, like a draft: replace its data (PUT /{requestId}) and add, replace or remove documents. It cannot be deleted. +- Submit it again: POST /{requestId}/submit, with the current version. The TSP checks it as a whole, as on the first submit. The request goes back to UNDER_REVIEW, with the same reviewer, and changeRequest.resubmittedAt records when. + +```mermaid +stateDiagram-v2 + [*] --> DRAFT : POST /onboarding-requests + + DRAFT --> DRAFT : PUT /{id} (replace request data) or POST/PUT/DELETE /{id}/documents (manage files) + DRAFT --> DELETED : DELETE (or auto-purge after 30 days) + DRAFT --> SUBMITTED : POST submit + + SUBMITTED --> UNDER_REVIEW : assign reviewer + + UNDER_REVIEW --> CHANGES_REQUESTED : request changes + UNDER_REVIEW --> APPROVED : approve + UNDER_REVIEW --> REJECTED : reject + + CHANGES_REQUESTED --> CHANGES_REQUESTED : PUT /{id} (replace request data) or POST/PUT/DELETE /{id}/documents (manage files) + CHANGES_REQUESTED --> UNDER_REVIEW : POST submit (reviewer already assigned) +``` + +4. Things to know - Versions: every response carries the request's version (in the body and ETag header). Every change must send it back in If-Match, otherwise the call is refused. - Retries: Idempotency-Key is optional on create/submit and makes a retry safe. - DID: the preferredDid must be new to the TSP. @@ -64,12 +87,24 @@ sequenceDiagram CSP-xTSP: PUT /{id} or any document change TSP-->>CSP: 422 request already submitted OP->>TSP: review, may edit the request and its documents - OP->>TSP: approve or reject + OP->>TSP: approve, reject or request changes end loop Follow-up CSP->>GW: GET /{id} GW->>TSP: relay - TSP-->>CSP: 200 { status: UNDER_REVIEW / ON_HOLD / APPROVED / REJECTED } + TSP-->>CSP: 200 { status: UNDER_REVIEW / CHANGES_REQUESTED / APPROVED / REJECTED } + end + + rect rgba(150,110,220,0.12) + Note over CSP,OP: Only when CHANGES_REQUESTED: the request is the CSP's again + CSP->>GW: GET /{id} + GW->>TSP: relay + TSP-->>CSP: 200 { status: CHANGES_REQUESTED, changeRequest: { comment, items[] } } + CSP->>GW: PUT /{id} and document changes, as on a draft (no DELETE /{id}) + GW->>TSP: relay + CSP->>GW: POST /{id}/submit (If-Match: version) + GW->>TSP: relay + TSP-->>CSP: 200 { status: UNDER_REVIEW, changeRequest.resubmittedAt } end ``` \ No newline at end of file diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/adapter/in/dto/OnboardingRequestView.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/adapter/in/dto/OnboardingRequestView.java index 500696c..f3a4b70 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/adapter/in/dto/OnboardingRequestView.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/adapter/in/dto/OnboardingRequestView.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.metaform.dxonboarding.domain.model.onboarding.Address; +import com.metaform.dxonboarding.domain.model.onboarding.ChangeRequest; import com.metaform.dxonboarding.domain.model.onboarding.CompanyType; import com.metaform.dxonboarding.domain.model.onboarding.Declarations; import com.metaform.dxonboarding.domain.model.onboarding.DocumentType; @@ -17,7 +18,8 @@ /** * An onboarding request with its status ({@code DataspaceOnboardingRequestDto}). {@code submittedAt} - * is absent while it is a draft, {@code decision} until a decision is taken. The GTC and use case + * is absent while it is a draft, {@code decision} until a decision is taken, {@code changeRequest} + * unless the review asked for changes. The GTC and use case * agreements carry no {@code versionId} nor {@code accepted}: the applicant names a version, and * signs it with the document. */ @@ -39,7 +41,8 @@ public record OnboardingRequestView( DocumentRefView registrationExtractDocument, DocumentRefView powerOfAttorneyDocument, List otherDocuments, - ReviewDecision decision) { + ReviewDecision decision, + ChangeRequest changeRequest) { public static OnboardingRequestView from(OnboardingRequest request) { var data = request.data(); @@ -72,7 +75,8 @@ public static OnboardingRequestView from(OnboardingRequest request) { request.document(DocumentType.REGISTRATION_EXTRACT, null).map(DocumentRefView::from).orElse(null), request.document(DocumentType.POWER_OF_ATTORNEY, null).map(DocumentRefView::from).orElse(null), request.documents(DocumentType.OTHER).stream().map(DocumentView::from).toList(), - request.decision()); + request.decision(), + request.changeRequest()); } /** diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/ApprovalService.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/ApprovalService.java index ac67283..7c4a4d3 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/ApprovalService.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/ApprovalService.java @@ -62,8 +62,8 @@ public ApprovalService(OnboardingRequestRepository repository, HolderRegistratio } /** - * Approves the request, if it still awaits review. Never throws: the outcome is the request's - * status. + * Approves the request, if it still awaits review — submitted, or under review. Never throws: + * the outcome is the request's status. */ public void approve(String requestId) { var inProgress = assignDecadeXId(requestId); @@ -92,8 +92,9 @@ public void approve(String requestId) { */ private synchronized Optional assignDecadeXId(String requestId) { var request = repository.findById(requestId).orElse(null); - if (request == null || request.status() != OnboardingStatus.SUBMITTED) { - // nothing else decides a request today, but an approval must never overwrite a decision + if (request == null + || request.status() != OnboardingStatus.SUBMITTED && request.status() != OnboardingStatus.UNDER_REVIEW) { + // an approval must never overwrite a decision, nor take a request its applicant is correcting return Optional.empty(); } var supplied = request.data().declaredLegalEntityId().orElse(null); diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/AutomaticReview.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/AutomaticReview.java index 203803b..0064382 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/AutomaticReview.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/AutomaticReview.java @@ -1,37 +1,65 @@ package com.metaform.dxonboarding.application; import com.metaform.dxonboarding.config.ReviewProperties; +import com.metaform.dxonboarding.domain.model.onboarding.ChangeRequest; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingRequest; +import com.metaform.dxonboarding.domain.model.onboarding.OnboardingStatus; +import com.metaform.dxonboarding.domain.port.OnboardingRequestRepository; +import java.time.Clock; +import java.util.List; +import java.util.Optional; import java.util.concurrent.Executor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; /** * Stands in for the TSP operator's review while the VE has no operator: decides for each new - * request whether it is approved automatically ({@link ReviewProperties#autoApprove()}) — the + * request whether it is reviewed automatically ({@link ReviewProperties#autoApprove()}) — the * requests of participants hosted here, and FOR NOW also those of external participants — or * waits in SUBMITTED for a review that does not exist yet. * - *

An approval runs on the review worker, after {@code dx-onboarding.review.delay}: the submitting - * call is acknowledged with SUBMITTED first, as the TSP's API describes, and the applicant reads the + *

An automatic review takes the request on ({@code UNDER_REVIEW}) and approves it — after asking + * for changes once, where {@link ReviewProperties#requestChanges()} says so: the request is handed + * back to the applicant ({@code CHANGES_REQUESTED}) with a {@link ChangeRequest} that asks for + * nothing, and approved once the applicant submitted it again. That is the round a real review may + * ask of a participant, which the VE has every participant go through. + * + *

The review runs on the review worker, each step after {@code dx-onboarding.review.delay}: the + * submitting call is acknowledged first, as the TSP's API describes, and the applicant reads the * outcome back from the request's status. What an approval does is the {@link ApprovalService}'s. */ @Component public class AutomaticReview { + /** What the automatic review asks: nothing, but that the request be submitted again. */ + static final String CHANGE_REQUEST_COMMENT = "Automatic review of the verification environment: every onboarding " + + "request is asked for changes once. Nothing needs to be corrected — submit the request again " + + "(POST /{requestId}/submit) to have it approved."; + private static final Logger log = LoggerFactory.getLogger(AutomaticReview.class); private final ApprovalService approval; + private final OnboardingRequestRepository repository; private final ReviewProperties properties; private final Executor reviewExecutor; + private final Clock clock; - public AutomaticReview(ApprovalService approval, ReviewProperties properties, - @Qualifier("reviewExecutor") Executor reviewExecutor) { + @Autowired + public AutomaticReview(ApprovalService approval, OnboardingRequestRepository repository, + ReviewProperties properties, @Qualifier("reviewExecutor") Executor reviewExecutor) { + this(approval, repository, properties, reviewExecutor, Clock.systemUTC()); + } + + AutomaticReview(ApprovalService approval, OnboardingRequestRepository repository, ReviewProperties properties, + Executor reviewExecutor, Clock clock) { this.approval = approval; + this.repository = repository; this.properties = properties; this.reviewExecutor = reviewExecutor; + this.clock = clock; } /** Takes a newly submitted request into review. */ @@ -44,20 +72,69 @@ public void submitted(OnboardingRequest request) { request.connectorId(), hosting); return; } - log.info("onboarding request '{}' of '{}' ({}) is approved automatically", request.id(), request.connectorId(), - hosting); - reviewExecutor.execute(() -> approve(request.id())); + var changesFirst = hostedHere ? properties.requestChanges().hosted() : properties.requestChanges().external(); + log.info("onboarding request '{}' of '{}' ({}) is reviewed automatically: {}", request.id(), + request.connectorId(), hosting, changesFirst ? "asked for changes once, then approved" : "approved"); + reviewExecutor.execute(() -> review(request.id(), changesFirst)); } - private void approve(String requestId) { + /** + * Takes a request back into review, which its applicant submitted again after a change request. + * Only the automatic review asks for changes, and only once: the request is approved. + */ + public void resubmitted(OnboardingRequest request) { + log.info("onboarding request '{}' of '{}' was submitted again: it is approved automatically", request.id(), + request.connectorId()); + reviewExecutor.execute(() -> { + if (waited()) { + approval.approve(request.id()); + } + }); + } + + private void review(String requestId, boolean changesFirst) { + if (!waited() || takeOn(requestId).isEmpty()) { + return; + } + if (changesFirst) { + requestChanges(requestId); + } else { + approval.approve(requestId); + } + } + + /** Assigns the reviewer: SUBMITTED becomes UNDER_REVIEW. Empty when the request is no longer there to review. */ + private synchronized Optional takeOn(String requestId) { + var underReview = repository.findById(requestId) + .filter(request -> request.status() == OnboardingStatus.SUBMITTED) + .map(OnboardingRequest::underReview); + underReview.ifPresent(repository::save); + return underReview; + } + + /** Hands the request back to its applicant: UNDER_REVIEW becomes CHANGES_REQUESTED. */ + private synchronized void requestChanges(String requestId) { + repository.findById(requestId) + .filter(request -> request.status() == OnboardingStatus.UNDER_REVIEW) + .map(request -> request.changesRequested( + ChangeRequest.of(clock.instant(), CHANGE_REQUEST_COMMENT, List.of()))) + .ifPresent(changesRequested -> { + repository.save(changesRequested); + log.info("onboarding request '{}' ({}) CHANGES_REQUESTED: handed back to '{}', to be submitted again", + changesRequested.id(), changesRequested.businessId(), changesRequested.connectorId()); + }); + } + + /** Waits for the review's delay; false when interrupted, which ends the review. */ + private boolean waited() { try { if (!properties.delay().isZero()) { Thread.sleep(properties.delay().toMillis()); } + return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - return; + return false; } - approval.approve(requestId); } } diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestService.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestService.java index 57467ab..aa0b46b 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestService.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestService.java @@ -10,6 +10,7 @@ import com.metaform.dxonboarding.domain.model.onboarding.OnboardingRequest; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingRequestData; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingStarted; +import com.metaform.dxonboarding.domain.model.onboarding.OnboardingStatus; import com.metaform.dxonboarding.domain.model.onboarding.SubmitCheck; import com.metaform.dxonboarding.domain.model.onboarding.UploadedFile; import com.metaform.dxonboarding.domain.model.onboarding.WriteCheck; @@ -32,7 +33,9 @@ * Keeps the drafts and their documents, recognizes a create or a submit sent again, and files a * complete draft as {@code SUBMITTED} — announcing its start and taking it into review * ({@link AutomaticReview}). The review is not part of this API: the applicant reads its outcome - * from the request's status. + * from the request's status. When that is {@code CHANGES_REQUESTED}, the request is the + * applicant's again: corrected like a draft, and submitted again, which hands it back to the + * review ({@code UNDER_REVIEW}). * *

Every change is synchronized: a version check, a quota or the DID's uniqueness must not * interleave with another change. Only resolving the DID happens outside the lock, as it leaves @@ -187,18 +190,24 @@ public OnboardingRequest submit(String connectorId, String requestId, long versi // the DID is resolved before taking the lock, as it leaves the process — and again under // it only when a correction changed the DID in the meantime var snapshot = owned(connectorId, requestId); - var resolution = snapshot.isDraft() + var resolution = snapshot.isEditable() ? snapshot.data().preferredDid().map(did -> new DidResolution(did, didResolver.problem(did))).orElse(null) : null; var outcome = file(connectorId, requestId, version, keyOrNull(idempotencyKey), resolution); - if (outcome.newlySubmitted()) { - var request = outcome.request(); - // announced before the review takes it on, so an observer sees the start before the outcome - events.onboardingStarted(new OnboardingStarted(request.id(), request.data().applicantReference(), - connectorId)); - review.submitted(request); + var request = outcome.request(); + switch (outcome.filing()) { + case SUBMITTED -> { + // announced before the review takes it on, so an observer sees the start before the outcome + events.onboardingStarted(new OnboardingStarted(request.id(), request.data().applicantReference(), + connectorId)); + review.submitted(request); + } + // the onboarding started with the first submit: nothing to announce + case RESUBMITTED -> review.resubmitted(request); + case REPLAYED -> { + } } - return outcome.request(); + return request; } /** @@ -217,7 +226,14 @@ public synchronized int deleteDraftsUntouchedSince(Instant cutoff) { private record DidResolution(String did, Optional problem) { } - private record Submitted(OnboardingRequest request, boolean newlySubmitted) { + /** What a submit did: filed a draft, filed a request again after a change request, or nothing. */ + private enum Filing { + SUBMITTED, + RESUBMITTED, + REPLAYED + } + + private record Submitted(OnboardingRequest request, Filing filing) { } private synchronized Submitted file(String connectorId, String requestId, long version, String key, @@ -228,10 +244,10 @@ private synchronized Submitted file(String connectorId, String requestId, long v throw new ProblemException(ProblemCode.IDEMPOTENCY_KEY_REUSED, "Idempotency-Key '%s' was already used to submit another request".formatted(key)); } - if (!request.isDraft()) { + if (!request.isEditable() || replaysTheFirstSubmit(request, version, key)) { log.info("onboarding request '{}' ({}) was already submitted, now {}: returned as it is", request.id(), request.businessId(), request.status()); - return new Submitted(request, false); + return new Submitted(request, Filing.REPLAYED); } request.requireVersion(version); var errors = new ArrayList<>(SubmitCheck.violations(request)); @@ -240,6 +256,14 @@ private synchronized Submitted file(String connectorId, String requestId, long v log.info("onboarding request '{}' ({}) cannot be submitted: {}", request.id(), request.businessId(), errors); throw ProblemException.submitRejected(errors); } + if (!request.isDraft()) { + // asked for changes: it awaits its decision already, so the quota does not apply again + var resubmitted = request.resubmitted(clock.instant(), key); + repository.save(resubmitted); + log.info("onboarding request '{}' ({}) submitted again by '{}', answering the change request of {}", + resubmitted.id(), resubmitted.businessId(), connectorId, resubmitted.changeRequest().requestedAt()); + return new Submitted(resubmitted, Filing.RESUBMITTED); + } if (repository.findByConnector(connectorId).stream().filter(other -> other.status().awaitsDecision()).count() >= MAX_AWAITING_DECISION) { throw new ProblemException(ProblemCode.CONNECTOR_PENDING_REQUEST_LIMIT_REACHED, @@ -250,7 +274,18 @@ private synchronized Submitted file(String connectorId, String requestId, long v repository.save(submitted); log.info("onboarding request '{}' ({}) submitted by '{}' for {}", submitted.id(), submitted.businessId(), connectorId, submitted.data().legalEntity().legalName()); - return new Submitted(submitted, true); + return new Submitted(submitted, Filing.SUBMITTED); + } + + /** + * Whether a submit of a request asked for changes is the first submit sent again — its response + * was lost, and the review answered before the retry — rather than the answer to the change + * request: it carries the key the request was submitted with, and a version from before that. + * Without a key the two cannot be told apart, and an outdated version is a conflict. + */ + private static boolean replaysTheFirstSubmit(OnboardingRequest request, long version, String key) { + return request.status() == OnboardingStatus.CHANGES_REQUESTED && key != null + && key.equals(request.replay().submitKey()) && version != request.version(); } /** The DID must be new to the TSP — held by no other request in progress or approved — and resolve. */ diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/OnboardingRequestService.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/OnboardingRequestService.java index 039c653..3a770a3 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/OnboardingRequestService.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/application/OnboardingRequestService.java @@ -10,7 +10,8 @@ /** * The TSP's onboarding intake, as a dataspace participant reaches it through the dataspace: a - * draft is created, completed with its documents, and submitted for review. Every call is a + * draft is created, completed with its documents, and submitted for review — and corrected and + * submitted again, when the review asks for changes. Every call is a * connector's, identified by what the dataspace stamped on it; requests are scoped to the * connector that created them, and another connector's request reads like an unknown one. * @@ -42,27 +43,31 @@ record DocumentFile(OnboardingDocument document, byte[] content) { OnboardingRequest get(String connectorId, String requestId); - /** Replaces the draft's data, dropping the signed documents that no longer match it. */ + /** + * Replaces the data of a draft — or of a request the review asked changes of —, dropping the + * signed documents that no longer match it. + */ OnboardingRequest replace(String connectorId, String requestId, long version, OnboardingRequestData data); - /** Deletes the draft and its documents, for good. */ + /** Deletes the draft and its documents, for good. Only a draft: a submitted request stays. */ void delete(String connectorId, String requestId, long version); DocumentChange addDocument(String connectorId, String requestId, long version, DocumentUpload upload); - /** Replaces the file of a document of the draft (and an {@code other}'s label); the document keeps its id. */ + /** Replaces the file of a document of the request (and an {@code other}'s label); the document keeps its id. */ DocumentChange replaceDocument(String connectorId, String requestId, UUID documentId, long version, String label, UploadedFile file); - /** Removes a document from the draft; returns the request at its new version. */ + /** Removes a document from the request; returns the request at its new version. */ OnboardingRequest removeDocument(String connectorId, String requestId, UUID documentId, long version); /** A document's file, whatever the request's status. */ DocumentFile download(String connectorId, String requestId, UUID documentId); /** - * Submits the draft for review, once it is complete and its DID is free and resolves. A - * request already submitted is returned as it is. + * Submits the draft for review, once it is complete and its DID is free and resolves — or + * submits a request the review asked changes of again, under the same checks. A request + * already submitted, and with the review, is returned as it is. * * @param idempotencyKey optional; a key already used to submit another request is refused */ diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/config/ReviewProperties.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/config/ReviewProperties.java index f3f798f..4af7a4b 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/config/ReviewProperties.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/config/ReviewProperties.java @@ -15,19 +15,35 @@ *

  • external — any other participant, e.g. a system under test.
  • * * + *

    An automatic review can ask for changes first ({@code requestChanges}): the request is handed + * back once ({@code CHANGES_REQUESTED}) and approved when the applicant submits it again, so that + * every participant goes through the correction round a real review may ask of it. + * * @param hostedDidPrefix the DID prefix of participants hosted by this VE * @param autoApprove which of the two are approved without an operator - * @param delay how long an automatic approval waits after the submission (0: right away, + * @param requestChanges which of the two are asked for changes once before they are approved; + * only the requests approved automatically + * @param delay how long an automatic review waits before each of its steps: after the + * submission, and after the request was submitted again (0: right away, * still after the submitting call returned) */ @ConfigurationProperties(prefix = "dx-onboarding.review") -public record ReviewProperties(String hostedDidPrefix, AutoApprove autoApprove, Duration delay) { +public record ReviewProperties(String hostedDidPrefix, AutoApprove autoApprove, RequestChanges requestChanges, + Duration delay) { public ReviewProperties { autoApprove = autoApprove == null ? new AutoApprove(false, false) : autoApprove; + requestChanges = requestChanges == null ? new RequestChanges(false, false) : requestChanges; delay = delay == null ? Duration.ZERO : delay; } + /** + * @param hosted ask the participants hosted here for changes once, before approving them + * @param external ask every other participant for changes once, before approving it + */ + public record RequestChanges(boolean hosted, boolean external) { + } + /** * @param hosted approve the requests of participants hosted here automatically * @param external approve every other participant's requests automatically — on FOR NOW; diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/ProblemCode.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/ProblemCode.java index 4fad338..0d89895 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/ProblemCode.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/ProblemCode.java @@ -12,7 +12,7 @@ public enum ProblemCode { IDEMPOTENCY_KEY_REUSED("idempotency-key-reused", 409, "Idempotency key reused"), NOT_FOUND("not-found", 404, "Not found"), UNAUTHORIZED("unauthorized", 401, "Unauthorized"), - INVALID_ONBOARDING_REQUEST_STATE("invalid-onboarding-request-state", 422, "Request no longer a draft"), + INVALID_ONBOARDING_REQUEST_STATE("invalid-onboarding-request-state", 422, "Request cannot be changed"), ONBOARDING_REQUEST_SUBMIT_REJECTED("onboarding-request-submit-rejected", 422, "Submit rejected"), CONNECTOR_DRAFT_LIMIT_REACHED("connector-draft-limit-reached", 422, "Draft limit reached"), CONNECTOR_PENDING_REQUEST_LIMIT_REACHED("connector-pending-request-limit-reached", 422, diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/ChangeRequest.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/ChangeRequest.java new file mode 100644 index 0000000..81297d5 --- /dev/null +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/ChangeRequest.java @@ -0,0 +1,44 @@ +package com.metaform.dxonboarding.domain.model.onboarding; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.List; + +/** + * What the review asks the applicant to change before it carries on: the request is + * {@code CHANGES_REQUESTED} until the applicant submits it again. + * + * @param requestedAt when the review asked + * @param comment what it asks, in the reviewer's words + * @param items the fields and documents it concerns; empty when the comment says it all + * @param resubmittedAt when the applicant submitted the request again; null until then + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ChangeRequest(Instant requestedAt, String comment, List items, Instant resubmittedAt) { + + public ChangeRequest { + items = items == null ? List.of() : List.copyOf(items); + } + + /** Changes asked for at the given instant, not answered yet. */ + public static ChangeRequest of(Instant requestedAt, String comment, List items) { + return new ChangeRequest(requestedAt, comment, items, null); + } + + /** Answered: the applicant submitted the request again. */ + public ChangeRequest resubmitted(Instant at) { + return new ChangeRequest(requestedAt, comment, items, at); + } + + /** + * One thing to change ({@code ChangeRequestItemDto}): a field of the request's data, or one of + * its documents. + * + * @param field the field concerned, e.g. {@code legalEntity.legalName}; null for a document + * @param documentType the kind of document concerned; null for a field + * @param note what is wrong with it + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Item(String field, DocumentType documentType, String note) { + } +} diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequest.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequest.java index 5382e57..36a3bff 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequest.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequest.java @@ -13,8 +13,9 @@ /** * An onboarding request and where it stands. It starts as a {@code DRAFT}, which its applicant * corrects and adds documents to; once submitted, it belongs to the review and no longer changes - * but for the review's decision. Every change makes a new {@link #version()}, which a change must - * name to be accepted ({@link #requireVersion}). + * but for the review's decision — unless the review asks for changes ({@code CHANGES_REQUESTED}): + * the applicant then corrects it like a draft, and submits it again. Every change makes a new + * {@link #version()}, which a change must name to be accepted ({@link #requireVersion}). * * @param id the id the applicant reads the request back by * @param businessId a human-readable reference, e.g. for a TSP operator @@ -24,6 +25,7 @@ * @param legalEntityId the participant's DECADE-X-ID — the TSP's id of the legal entity; null * until approval assigns it * @param decision null until a decision is taken + * @param changeRequest what the review asked to change; null unless it ever did * @param replay what recognizes a call sent again */ public record OnboardingRequest( @@ -39,6 +41,7 @@ public record OnboardingRequest( List documents, String legalEntityId, ReviewDecision decision, + ChangeRequest changeRequest, Replay replay) { public static final int MAX_OTHER_DOCUMENTS = 5; @@ -55,8 +58,9 @@ public record OnboardingRequest( * @param createKey the {@code Idempotency-Key} it was created with; null without one * @param createdWith the data it was created with: a create without a key and with the same * data is the same create - * @param submitKey the {@code Idempotency-Key} it was submitted with; null without one, and - * while it is a draft + * @param submitKey the {@code Idempotency-Key} it was submitted with — last, when it was + * submitted again after a change request; null without one, and while it + * is a draft */ public record Replay(String createKey, OnboardingRequestData createdWith, String submitKey) { } @@ -65,7 +69,7 @@ public record Replay(String createKey, OnboardingRequestData createdWith, String public static OnboardingRequest draft(String id, String businessId, String connectorId, OnboardingRequestData data, String createKey, Instant now) { return new OnboardingRequest(id, businessId, connectorId, 1, now, now, null, OnboardingStatus.DRAFT, data, - List.of(), null, null, new Replay(createKey, data, null)); + List.of(), null, null, null, new Replay(createKey, data, null)); } /** Refuses a change that names another version than the current one: the caller read it before a change. */ @@ -81,16 +85,21 @@ public boolean isDraft() { return status == OnboardingStatus.DRAFT; } - // --- the applicant's changes to the draft ------------------------------------------------- + /** Whether the applicant may change it: a draft, or a request the review asked changes of. */ + public boolean isEditable() { + return status.editable(); + } + + // --- the applicant's changes, to a draft or to a request the review asked changes of ------ /** - * Replaces the draft's data. The documents stay, but for the signed copies that no longer + * Replaces the request's data. The documents stay, but for the signed copies that no longer * match it ({@link #staleDocuments}). */ public OnboardingRequest withData(OnboardingRequestData newData, Instant at) { - requireDraft(); + requireEditable(); var stale = staleDocuments(newData); - return draftChange(newData, documents.stream().filter(document -> !stale.contains(document)).toList(), at); + return applicantChange(newData, documents.stream().filter(document -> !stale.contains(document)).toList(), at); } /** @@ -116,7 +125,7 @@ public List staleDocuments(OnboardingRequestData newData) { * 5 {@code other} documents, and past 50 MB of documents. */ public OnboardingRequest withDocument(UUID documentId, DocumentUpload upload, Instant at) { - requireDraft(); + requireEditable(); var type = upload.type(); if (type == DocumentType.UCA && data.uca(upload.useCaseId()).isEmpty()) { throw new ProblemException(ProblemCode.USE_CASE_NOT_IN_REQUEST, @@ -141,16 +150,16 @@ public OnboardingRequest withDocument(UUID documentId, DocumentUpload upload, In file.mediaType(), file.size(), at, validUntil); var withDocument = new ArrayList<>(documents); withDocument.add(document); - return draftChange(data, withDocument, at); + return applicantChange(data, withDocument, at); } /** - * Replaces the file of one of the draft's documents, and an {@code other}'s label with it. The + * Replaces the file of one of the request's documents, and an {@code other}'s label with it. The * document keeps its id, type and use case; the new file counts toward the 50 MB instead of * the old one. */ public OnboardingRequest withReplacedFile(UUID documentId, String label, UploadedFile file, Instant at) { - requireDraft(); + requireEditable(); var existing = document(documentId).orElseThrow(() -> unknownDocument(documentId)); DocumentUpload.checkLabel(existing.type(), label); file.check(); @@ -159,30 +168,62 @@ public OnboardingRequest withReplacedFile(UUID documentId, String label, Uploade var replaced = new OnboardingDocument(documentId, existing.type(), existing.useCaseId(), existing.type() == DocumentType.OTHER ? label : null, file.filename(), file.mediaType(), file.size(), at, validUntil); - return draftChange(data, documents.stream() + return applicantChange(data, documents.stream() .map(document -> document.documentId().equals(documentId) ? replaced : document) .toList(), at); } public OnboardingRequest withoutDocument(UUID documentId, Instant at) { - requireDraft(); + requireEditable(); if (document(documentId).isEmpty()) { throw unknownDocument(documentId); } - return draftChange(data, documents.stream() + return applicantChange(data, documents.stream() .filter(document -> !document.documentId().equals(documentId)) .toList(), at); } /** Submitted for review: from now on it belongs to the review. What makes a draft complete is {@link SubmitCheck}'s. */ public OnboardingRequest submitted(Instant at, String submitKey) { - requireDraft(); + if (!isDraft()) { + throw new ProblemException(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, + "The request has already been submitted (%s)".formatted(status)); + } return new OnboardingRequest(id, businessId, connectorId, version + 1, createdAt, at, at, - OnboardingStatus.SUBMITTED, data, documents, legalEntityId, decision, + OnboardingStatus.SUBMITTED, data, documents, legalEntityId, decision, changeRequest, new Replay(replay.createKey(), replay.createdWith(), submitKey)); } - // --- the review's decision ---------------------------------------------------------------- + /** + * Submitted again, after the changes the review asked for: back under review, with the change + * request answered. {@code submittedAt} stays when it was first submitted. + * + * @param submitKey the {@code Idempotency-Key} of this submit; without one, the earlier one stays + */ + public OnboardingRequest resubmitted(Instant at, String submitKey) { + if (status != OnboardingStatus.CHANGES_REQUESTED) { + throw new IllegalStateException("Onboarding request %s was not asked for changes (%s)".formatted(id, status)); + } + return new OnboardingRequest(id, businessId, connectorId, version + 1, createdAt, at, submittedAt, + OnboardingStatus.UNDER_REVIEW, data, documents, legalEntityId, decision, changeRequest.resubmitted(at), + new Replay(replay.createKey(), replay.createdWith(), submitKey == null ? replay.submitKey() : submitKey)); + } + + // --- the review --------------------------------------------------------------------------- + + /** Taken on by the review: a reviewer is assigned. */ + public OnboardingRequest underReview() { + return reviewed(OnboardingStatus.UNDER_REVIEW, legalEntityId, null); + } + + /** + * Handed back to the applicant, who corrects the request and submits it again; a change + * request it answered earlier is replaced. + */ + public OnboardingRequest changesRequested(ChangeRequest request) { + return new OnboardingRequest(id, businessId, connectorId, version + 1, createdAt, updatedAt, submittedAt, + OnboardingStatus.CHANGES_REQUESTED, data, documents, legalEntityId, decision, request, replay); + } /** * Approved, and being provisioned under the given DECADE-X-ID: the participant is registered @@ -239,11 +280,22 @@ public long documentsBytes() { return documents.stream().mapToLong(OnboardingDocument::sizeBytes).sum(); } - /** Refuses a change of a request that is no longer a draft: it belongs to the review. */ + /** + * Refuses a change of a request the applicant may not change: it belongs to the review, unless + * the review asked for changes. + */ + public void requireEditable() { + if (!isEditable()) { + throw new ProblemException(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, + "The request has been submitted (%s) and can no longer be changed".formatted(status)); + } + } + + /** Refuses what only a draft allows — deleting it: a submitted request stays, whatever the review asks. */ public void requireDraft() { if (!isDraft()) { throw new ProblemException(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, - "The request has been submitted (%s) and can no longer be changed".formatted(status)); + "The request has been submitted (%s) and can no longer be deleted".formatted(status)); } } @@ -264,15 +316,15 @@ private LocalDate powerOfAttorneyValidUntil() { return validUntil; } - private OnboardingRequest draftChange(OnboardingRequestData newData, List newDocuments, - Instant at) { + private OnboardingRequest applicantChange(OnboardingRequestData newData, List newDocuments, + Instant at) { return new OnboardingRequest(id, businessId, connectorId, version + 1, createdAt, at, submittedAt, status, - newData, newDocuments, legalEntityId, decision, replay); + newData, newDocuments, legalEntityId, decision, changeRequest, replay); } private OnboardingRequest reviewed(OnboardingStatus newStatus, String newLegalEntityId, ReviewDecision newDecision) { return new OnboardingRequest(id, businessId, connectorId, version + 1, createdAt, updatedAt, submittedAt, - newStatus, data, documents, newLegalEntityId, newDecision, replay); + newStatus, data, documents, newLegalEntityId, newDecision, changeRequest, replay); } private static ProblemException unknownDocument(UUID documentId) { diff --git a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingStatus.java b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingStatus.java index 69fe3df..c95bb2b 100644 --- a/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingStatus.java +++ b/dx-onboarding-api/src/main/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingStatus.java @@ -2,23 +2,29 @@ /** * Where an onboarding request stands. A request starts as a {@code DRAFT}, which the applicant - * completes and submits ({@code SUBMITTED}); a TSP operator's review moves it on to - * {@code APPROVED} or {@code REJECTED}, through the intermediate steps of the review and of the - * provisioning that follows an approval. + * completes and submits ({@code SUBMITTED}); the review takes it on ({@code UNDER_REVIEW}) and + * moves it on to {@code APPROVED} or {@code REJECTED}, through the provisioning that follows an + * approval — or hands it back to the applicant ({@code CHANGES_REQUESTED}), who corrects it and + * submits it again ({@code UNDER_REVIEW}). */ public enum OnboardingStatus { DRAFT, SUBMITTED, UNDER_REVIEW, - ON_HOLD, + CHANGES_REQUESTED, APPROVAL_IN_PROGRESS, APPROVAL_FAILED, APPROVED, REJECTED; + /** The applicant may change the request: a draft, or a request the review asked changes of. */ + public boolean editable() { + return this == DRAFT || this == CHANGES_REQUESTED; + } + /** Submitted, and no decision taken yet: what the pending-request quota counts. */ public boolean awaitsDecision() { - return this == SUBMITTED || this == UNDER_REVIEW || this == ON_HOLD || this == APPROVAL_IN_PROGRESS; + return this == SUBMITTED || this == UNDER_REVIEW || this == CHANGES_REQUESTED || this == APPROVAL_IN_PROGRESS; } /** In progress or approved: a request in this state holds its DID, which no other request may submit. */ diff --git a/dx-onboarding-api/src/main/resources/application.yaml b/dx-onboarding-api/src/main/resources/application.yaml index 32a0d96..3df5c55 100644 --- a/dx-onboarding-api/src/main/resources/application.yaml +++ b/dx-onboarding-api/src/main/resources/application.yaml @@ -81,6 +81,9 @@ dx-onboarding: # identity under hosted-did-prefix: the verification participant, the e2e tests' participants) # are approved; EXTERNAL ones (systems under test) too, for now — switch that off to have them # wait for an operator's review. + # An automatic review asks for changes once before it approves (request-changes): the request + # becomes CHANGES_REQUESTED, with a changeRequest that asks for nothing, and is approved once + # the applicant submitted it again — the round a real review may ask of any participant. review: # The DID prefix the Membership Hub mints its participants' DIDs under (its # participant.did.template); host-derived: the install scripts override it for a non-default host. @@ -88,6 +91,10 @@ dx-onboarding: auto-approve: hosted: true # DXONBOARDING_REVIEW_AUTOAPPROVE_HOSTED external: true # DXONBOARDING_REVIEW_AUTOAPPROVE_EXTERNAL - # How long an automatic approval waits after the submission (0: right away — still after the - # submitting call returned SUBMITTED). + # Which automatically reviewed requests are asked for changes once, before they are approved. + request-changes: + hosted: true # DXONBOARDING_REVIEW_REQUESTCHANGES_HOSTED + external: true # DXONBOARDING_REVIEW_REQUESTCHANGES_EXTERNAL + # How long an automatic review waits before each of its steps: after the submission, and after + # the request was submitted again (0: right away — still after the submitting call returned). delay: 0s # DXONBOARDING_REVIEW_DELAY diff --git a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/OnboardingRequestControllerTest.java b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/OnboardingRequestControllerTest.java index ddc5438..d2f24bb 100644 --- a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/OnboardingRequestControllerTest.java +++ b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/OnboardingRequestControllerTest.java @@ -7,10 +7,14 @@ import com.metaform.dxonboarding.application.DefaultOnboardingRequestService; import com.metaform.dxonboarding.config.ReviewConfig; import com.metaform.dxonboarding.config.SecurityConfig; +import com.metaform.dxonboarding.domain.model.onboarding.ChangeRequest; +import com.metaform.dxonboarding.domain.model.onboarding.DocumentType; import com.metaform.dxonboarding.domain.port.DidResolver; import com.metaform.dxonboarding.domain.port.OnboardingEventPublisher; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -66,6 +70,10 @@ class OnboardingRequestControllerTest { @Autowired private MockMvc mvc; + /** Where the review's part is played, which the API has no call for. */ + @Autowired + private InMemoryOnboardingRequestRepository repository; + @MockitoBean private AutomaticReview review; @@ -394,6 +402,53 @@ void submit_checksTheDraftAsAWhole_thenFilesIt_andTheRequestIsReadOnly() throws .andExpect(jsonPath("$.documents.length()").value(3)); } + @Test + void aRequestAskedForChanges_showsWhatIsAsked_isCorrected_andSubmittedAgain() throws Exception { + var id = completeSample(); + submit(CONNECTOR, id, "\"4\"").andExpect(status().isOk()); + // the review takes it on and hands it back: versions 6 and 7 + repository.save(repository.findById(id).orElseThrow().underReview().changesRequested(ChangeRequest.of( + Instant.parse("2026-10-02T10:00:00Z"), "The GTC copy is not signed", + List.of(new ChangeRequest.Item(null, DocumentType.GTC, "signature missing"), + new ChangeRequest.Item("legalEntity.website", null, "does not resolve"))))); + + mvc.perform(as(get(PATH + "/{id}", id), CONNECTOR)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, "\"7\"")) + .andExpect(jsonPath("$.status").value("CHANGES_REQUESTED")) + .andExpect(jsonPath("$.changeRequest.requestedAt").value("2026-10-02T10:00:00Z")) + .andExpect(jsonPath("$.changeRequest.comment").value("The GTC copy is not signed")) + .andExpect(jsonPath("$.changeRequest.items.length()").value(2)) + .andExpect(jsonPath("$.changeRequest.items[0].documentType").value("gtc")) + .andExpect(jsonPath("$.changeRequest.items[0].note").value("signature missing")) + .andExpect(jsonPath("$.changeRequest.items[0].field").doesNotExist()) + .andExpect(jsonPath("$.changeRequest.items[1].field").value("legalEntity.website")) + .andExpect(jsonPath("$.changeRequest.resubmittedAt").doesNotExist()); + + // the applicant's again: corrected like a draft, but not deleted + mvc.perform(as(put(PATH + "/{id}", id), CONNECTOR).header(HttpHeaders.IF_MATCH, "\"7\"") + .contentType(MediaType.APPLICATION_JSON).content(sampleRequest())) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, "\"8\"")) + .andExpect(jsonPath("$.status").value("CHANGES_REQUESTED")); + addDocument(id, "\"8\"", "other", null, "Signed GTC", pdf("gtc-signed.pdf")).andExpect(status().isCreated()); + problem(mvc.perform(as(delete(PATH + "/{id}", id), CONNECTOR).header(HttpHeaders.IF_MATCH, "\"9\"")), + 422, "invalid-onboarding-request-state"); + + submit(CONNECTOR, id, "\"9\"") + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, "\"10\"")) + .andExpect(jsonPath("$.status").value("UNDER_REVIEW")) + .andExpect(jsonPath("$.changeRequest.resubmittedAt").isNotEmpty()) + .andExpect(jsonPath("$.otherDocuments[0].label").value("Signed GTC")); + verify(review).resubmitted(any()); + // the onboarding started once, with the first submit + verify(events).onboardingStarted(any()); + // with the review again + problem(addDocument(id, "\"10\"", "other", null, "Late", pdf("late.pdf")), 422, + "invalid-onboarding-request-state"); + } + @Test void submit_refusesADidThatDoesNotResolve() throws Exception { when(didResolver.problem(DID)).thenReturn(java.util.Optional.of("no DID document could be fetched")); diff --git a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/SpecConformanceTest.java b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/SpecConformanceTest.java index a955910..ab2b5ec 100644 --- a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/SpecConformanceTest.java +++ b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/adapter/in/web/SpecConformanceTest.java @@ -7,13 +7,18 @@ import com.metaform.dxonboarding.config.ReviewConfig; import com.metaform.dxonboarding.config.SecurityConfig; import com.metaform.dxonboarding.domain.ProblemCode; +import com.metaform.dxonboarding.domain.model.onboarding.ChangeRequest; +import com.metaform.dxonboarding.domain.model.onboarding.DocumentType; +import com.metaform.dxonboarding.domain.model.onboarding.OnboardingStatus; import com.metaform.dxonboarding.domain.port.DidResolver; import com.metaform.dxonboarding.domain.port.OnboardingEventPublisher; import java.io.IOException; +import java.lang.reflect.RecordComponent; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -28,7 +33,10 @@ import static org.assertj.core.api.Assertions.assertThat; -/** The stand-in serves the TSP's specification ({@code dataspace-api-docs.yaml}): every operation, every problem code. */ +/** + * The stand-in serves the TSP's specification ({@code dataspace-api-docs.yaml}): every operation, + * every problem code, every status a request reports, and the change request the review hands back. + */ @WebMvcTest(controllers = OnboardingRequestController.class) @Import({SecurityConfig.class, ReviewConfig.class, UnknownProperties.class, DefaultOnboardingRequestService.class, InMemoryOnboardingRequestRepository.class, InMemoryDocumentStore.class}) @@ -83,4 +91,23 @@ void everyProblemCodeOfTheSpec_isOneOfOurs() throws IOException { assertThat(specified).hasSize(19); assertThat(Arrays.stream(ProblemCode.values()).map(ProblemCode::code)).containsExactlyInAnyOrderElementsOf(specified); } + + @Test + @SuppressWarnings("unchecked") + void everyStatusOfTheSpec_isOneOfOurs_andTheChangeRequestIsServedAsSpecified() throws IOException { + var schemas = (Map>) ((Map) spec().get("components")).get("schemas"); + var request = (Map>) schemas.get("DataspaceOnboardingRequestDto").get("properties"); + var changeRequest = (Map) schemas.get("ChangeRequest").get("properties"); + var item = (Map>) schemas.get("ChangeRequestItemDto").get("properties"); + + assertThat(Arrays.stream(OnboardingStatus.values()).map(Enum::name)) + .containsExactlyElementsOf((List) request.get("status").get("enum")); + assertThat(request).containsKey("changeRequest"); + assertThat(Arrays.stream(ChangeRequest.class.getRecordComponents()).map(RecordComponent::getName)) + .containsExactlyInAnyOrderElementsOf(changeRequest.keySet()); + assertThat(Arrays.stream(ChangeRequest.Item.class.getRecordComponents()).map(RecordComponent::getName)) + .containsExactlyInAnyOrderElementsOf(item.keySet()); + assertThat(Arrays.stream(DocumentType.values()).map(DocumentType::wireName)) + .containsExactlyElementsOf((List) item.get("documentType").get("enum")); + } } diff --git a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/ApprovalServiceTest.java b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/ApprovalServiceTest.java index f49a37a..9d0cb2f 100644 --- a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/ApprovalServiceTest.java +++ b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/ApprovalServiceTest.java @@ -1,6 +1,7 @@ package com.metaform.dxonboarding.application; import com.metaform.dxonboarding.adapter.out.persistence.InMemoryOnboardingRequestRepository; +import com.metaform.dxonboarding.domain.model.onboarding.ChangeRequest; import com.metaform.dxonboarding.domain.model.onboarding.DecadeXId; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingCompleted; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingRequest; @@ -174,4 +175,23 @@ void aRequestNoLongerAwaitingReview_isLeftAlone() { assertThat(stored(request)).isEqualTo(rejected); assertThat(events.completed).isEmpty(); } + + @Test + void aRequestUnderReview_isApproved_withTheChangeRequestItAnswered_butNotWhileItsApplicantCorrectsIt() { + var asked = filed(EXTERNAL, null).underReview().changesRequested(ChangeRequest.of(DECIDED_AT, "Please confirm", null)); + repository.save(asked); + + approval.approve(asked.id()); + + // handed back to the applicant: not the review's to approve + assertThat(stored(asked)).isEqualTo(asked); + assertThat(calls).isEmpty(); + + repository.save(asked.resubmitted(DECIDED_AT, null)); + approval.approve(asked.id()); + + assertThat(stored(asked).status()).isEqualTo(OnboardingStatus.APPROVED); + assertThat(stored(asked).changeRequest().resubmittedAt()).isEqualTo(DECIDED_AT); + assertThat(events.completed).hasSize(1); + } } diff --git a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/AutomaticReviewTest.java b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/AutomaticReviewTest.java index 318caa2..350e296 100644 --- a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/AutomaticReviewTest.java +++ b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/AutomaticReviewTest.java @@ -1,9 +1,13 @@ package com.metaform.dxonboarding.application; +import com.metaform.dxonboarding.adapter.out.persistence.InMemoryOnboardingRequestRepository; import com.metaform.dxonboarding.config.ReviewProperties; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingRequest; +import com.metaform.dxonboarding.domain.model.onboarding.OnboardingStatus; import com.metaform.dxonboarding.domain.model.onboarding.Requests; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -11,48 +15,65 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * The stand-in for the TSP operator: participants hosted here are approved automatically, external - * ones as configured (for now: approved too) — always after the submission was acknowledged. + * The stand-in for the TSP operator: participants hosted here are reviewed automatically, external + * ones as configured (for now: reviewed too) — always after the submission was acknowledged. An + * automatic review approves the request, after asking for changes once where configured. */ class AutomaticReviewTest { private static final String HOSTED_PREFIX = "did:web:identity.cxve.localhost:"; private static final String HOSTED = HOSTED_PREFIX + "verification-participant-dx"; private static final String EXTERNAL = "did:web:sut.example.com"; + private static final Instant REVIEWED_AT = Instant.parse("2026-10-02T10:00:05Z"); + private final InMemoryOnboardingRequestRepository repository = new InMemoryOnboardingRequestRepository(); private final List pending = new ArrayList<>(); + /** The requests approved, each with the status it was approved in. */ private final List approved = new ArrayList<>(); private final ApprovalService approval = new ApprovalService(null, null, null, null, null) { @Override public void approve(String requestId) { - approved.add(requestId); + approved.add(requestId + ":" + repository.findById(requestId).orElseThrow().status()); } }; + /** A review that approves as given, without asking for changes. */ private AutomaticReview review(boolean hosted, boolean external) { - return new AutomaticReview(approval, - new ReviewProperties(HOSTED_PREFIX, new ReviewProperties.AutoApprove(hosted, external), null), - pending::add); + return review(new ReviewProperties.AutoApprove(hosted, external), new ReviewProperties.RequestChanges(false, false)); } - private static OnboardingRequest filed(String id, String connectorId) { - return OnboardingRequest.draft(id, "DX-OR-000001", connectorId, Requests.data(connectorId), null, + private AutomaticReview review(ReviewProperties.AutoApprove autoApprove, ReviewProperties.RequestChanges requestChanges) { + return new AutomaticReview(approval, repository, + new ReviewProperties(HOSTED_PREFIX, autoApprove, requestChanges, null), pending::add, + Clock.fixed(REVIEWED_AT, ZoneOffset.UTC)); + } + + private OnboardingRequest filed(String id, String connectorId) { + var request = OnboardingRequest.draft(id, "DX-OR-000001", connectorId, Requests.data(connectorId), null, Instant.parse("2026-10-02T10:00:00Z")).submitted(Instant.parse("2026-10-02T10:00:01Z"), null); + repository.save(request); + return request; + } + + private OnboardingRequest stored(String id) { + return repository.findById(id).orElseThrow(); } private void runWorker() { - pending.forEach(Runnable::run); + var due = List.copyOf(pending); pending.clear(); + due.forEach(Runnable::run); } @Test - void aParticipantHostedHere_isApprovedAfterTheSubmission() { + void aParticipantHostedHere_isApprovedAfterTheSubmission_onceUnderReview() { review(true, false).submitted(filed("req-1", HOSTED)); - // acknowledged as SUBMITTED first: the approval runs on the worker, after the submission + // acknowledged as SUBMITTED first: the review runs on the worker, after the submission assertThat(approved).isEmpty(); + assertThat(stored("req-1").status()).isEqualTo(OnboardingStatus.SUBMITTED); runWorker(); - assertThat(approved).containsExactly("req-1"); + assertThat(approved).containsExactly("req-1:UNDER_REVIEW"); } @Test @@ -61,7 +82,8 @@ void anExternalParticipant_isApprovedOnlyWhileExternalAutoApprovalIsOn() { review(true, false).submitted(filed("req-2", EXTERNAL)); runWorker(); - assertThat(approved).containsExactly("req-1"); + assertThat(approved).containsExactly("req-1:UNDER_REVIEW"); + assertThat(stored("req-2").status()).isEqualTo(OnboardingStatus.SUBMITTED); } @Test @@ -72,12 +94,70 @@ void aParticipantHostedHere_waitsWhenHostedAutoApprovalIsOff() { assertThat(approved).isEmpty(); } + @Test + void aRequest_isAskedForChangesOnce_andApprovedWhenSubmittedAgain() { + var review = review(new ReviewProperties.AutoApprove(true, true), new ReviewProperties.RequestChanges(true, true)); + var filed = filed("req-1", EXTERNAL); + + review.submitted(filed); + runWorker(); + + var asked = stored("req-1"); + assertThat(approved).isEmpty(); + assertThat(asked.status()).isEqualTo(OnboardingStatus.CHANGES_REQUESTED); + // taken on, then handed back: two steps of the review + assertThat(asked.version()).isEqualTo(filed.version() + 2); + assertThat(asked.changeRequest().requestedAt()).isEqualTo(REVIEWED_AT); + assertThat(asked.changeRequest().comment()).isEqualTo(AutomaticReview.CHANGE_REQUEST_COMMENT); + assertThat(asked.changeRequest().items()).isEmpty(); + assertThat(asked.changeRequest().resubmittedAt()).isNull(); + + var resubmitted = asked.resubmitted(REVIEWED_AT.plusSeconds(60), null); + repository.save(resubmitted); + review.resubmitted(resubmitted); + + assertThat(approved).isEmpty(); + runWorker(); + assertThat(approved).containsExactly("req-1:UNDER_REVIEW"); + } + + @Test + void changesAreRequested_ofHostedAndExternalParticipantsAsConfigured_andOnlyOfThoseReviewedAutomatically() { + var hostedOnly = review(new ReviewProperties.AutoApprove(true, true), new ReviewProperties.RequestChanges(true, false)); + hostedOnly.submitted(filed("hosted", HOSTED)); + hostedOnly.submitted(filed("external", EXTERNAL)); + // not reviewed automatically: it awaits an operator, who alone could ask it for changes + review(new ReviewProperties.AutoApprove(false, false), new ReviewProperties.RequestChanges(true, true)) + .submitted(filed("waiting", EXTERNAL)); + runWorker(); + + assertThat(stored("hosted").status()).isEqualTo(OnboardingStatus.CHANGES_REQUESTED); + assertThat(approved).containsExactly("external:UNDER_REVIEW"); + assertThat(stored("waiting").status()).isEqualTo(OnboardingStatus.SUBMITTED); + } + + @Test + void aRequestNoLongerSubmitted_isLeftAlone() { + var review = review(new ReviewProperties.AutoApprove(true, true), new ReviewProperties.RequestChanges(true, true)); + var filed = filed("req-1", EXTERNAL); + review.submitted(filed); + var rejected = filed.rejected(REVIEWED_AT, null, "decided otherwise", false); + repository.save(rejected); + + runWorker(); + + assertThat(stored("req-1")).isEqualTo(rejected); + assertThat(approved).isEmpty(); + } + @Test void withoutAHostedPrefix_noParticipantCountsAsHostedHere() { - var properties = new ReviewProperties(" ", new ReviewProperties.AutoApprove(true, false), null); + var properties = new ReviewProperties(" ", new ReviewProperties.AutoApprove(true, false), null, null); assertThat(properties.hostedHere(HOSTED)).isFalse(); - assertThat(new ReviewProperties(HOSTED_PREFIX, null, null).hostedHere(HOSTED)).isTrue(); - assertThat(new ReviewProperties(HOSTED_PREFIX, null, null).hostedHere(EXTERNAL)).isFalse(); + assertThat(new ReviewProperties(HOSTED_PREFIX, null, null, null).hostedHere(HOSTED)).isTrue(); + assertThat(new ReviewProperties(HOSTED_PREFIX, null, null, null).hostedHere(EXTERNAL)).isFalse(); + // nobody is asked for changes unless configured + assertThat(properties.requestChanges()).isEqualTo(new ReviewProperties.RequestChanges(false, false)); } } diff --git a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestServiceTest.java b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestServiceTest.java index c06eab3..0d18875 100644 --- a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestServiceTest.java +++ b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/application/DefaultOnboardingRequestServiceTest.java @@ -5,6 +5,7 @@ import com.metaform.dxonboarding.config.ReviewProperties; import com.metaform.dxonboarding.domain.FieldError; import com.metaform.dxonboarding.domain.ProblemCode; +import com.metaform.dxonboarding.domain.model.onboarding.ChangeRequest; import com.metaform.dxonboarding.domain.model.onboarding.DocumentType; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingRequest; import com.metaform.dxonboarding.domain.model.onboarding.OnboardingStarted; @@ -38,8 +39,9 @@ /** * The intake's rules across requests: drafts recognized when created again, documents kept with - * their drafts, a submit that checks the DID across every request and the quotas, and requests - * scoped to the connector that created them. + * their drafts, a submit that checks the DID across every request and the quotas, a request the + * review asked changes of corrected and submitted again, and requests scoped to the connector that + * created them. */ class DefaultOnboardingRequestServiceTest { @@ -53,14 +55,21 @@ class DefaultOnboardingRequestServiceTest { private final InMemoryDocumentStore documents = new InMemoryDocumentStore(); /** The requests taken into review; nothing is approved here — AutomaticReviewTest covers that. */ private final List reviewed = new ArrayList<>(); + /** The requests taken back into review, submitted again after a change request. */ + private final List reviewedAgain = new ArrayList<>(); private final ReviewProperties reviewProperties = - new ReviewProperties(HOSTED_PREFIX, new ReviewProperties.AutoApprove(false, false), null); + new ReviewProperties(HOSTED_PREFIX, new ReviewProperties.AutoApprove(false, false), null, null); private final RecordingEvents events = new RecordingEvents(); - private final AutomaticReview review = new AutomaticReview(null, reviewProperties, Runnable::run) { + private final AutomaticReview review = new AutomaticReview(null, repository, reviewProperties, Runnable::run) { @Override public void submitted(OnboardingRequest request) { reviewed.add(request.id()); } + + @Override + public void resubmitted(OnboardingRequest request) { + reviewedAgain.add(request.id()); + } }; /** Why a DID does not resolve, by DID; a DID not listed resolves. */ private final Map unresolvable = new HashMap<>(); @@ -82,6 +91,14 @@ private OnboardingRequest submit(OnboardingRequest draft) { return service.submit(draft.connectorId(), draft.id(), draft.version(), null); } + /** The review's part, which is not the service's: takes the submitted request on and asks for changes. */ + private OnboardingRequest askForChanges(OnboardingRequest submitted) { + var changesRequested = repository.findById(submitted.id()).orElseThrow().underReview() + .changesRequested(ChangeRequest.of(clock.instant(), "Please correct the reference", List.of())); + repository.save(changesRequested); + return changesRequested; + } + @Test void aDraft_isCreatedForTheConnector_andReadBackByItOnly() { var created = service.create(ACME, data(ACME), null); @@ -235,6 +252,84 @@ void aSubmit_filesTheRequest_announcesIt_andTakesItIntoReview_once() { assertThat(resolved).containsExactly(ACME); } + @Test + void aRequestAskedForChanges_isCorrectedLikeADraft_andSubmittedAgain() { + var asked = askForChanges(service.submit(ACME, complete(ACME).id(), 3, "submit-1")); + clock.advance(Duration.ofMinutes(5)); + + var corrected = service.replace(ACME, asked.id(), asked.version(), + withApplicantReference(asked.data(), "ref-2")); + var added = service.addDocument(ACME, asked.id(), corrected.version(), other("Insurance")); + var replaced = service.replaceDocument(ACME, asked.id(), added.document().documentId(), + added.request().version(), "Insurance 2027", pdfFile("insurance-2027.pdf")); + var removed = service.removeDocument(ACME, asked.id(), added.document().documentId(), + replaced.request().version()); + + assertThat(removed.status()).isEqualTo(OnboardingStatus.CHANGES_REQUESTED); + assertThat(removed.data().applicantReference()).isEqualTo("ref-2"); + assertThat(removed.documents()).extracting(document -> document.type()) + .containsExactly(DocumentType.GTC, DocumentType.UCA); + // a submitted request stays, whatever the review asks + problem(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, () -> service.delete(ACME, asked.id(), removed.version())); + problem(ProblemCode.VERSION_CONFLICT, () -> service.submit(ACME, asked.id(), asked.version(), null)); + + var resubmitted = service.submit(ACME, asked.id(), removed.version(), "submit-1"); + var again = service.submit(ACME, asked.id(), removed.version(), "submit-1"); + + assertThat(resubmitted.status()).isEqualTo(OnboardingStatus.UNDER_REVIEW); + assertThat(resubmitted.version()).isEqualTo(removed.version() + 1); + assertThat(resubmitted.submittedAt()).isEqualTo(NOW); + assertThat(resubmitted.changeRequest().resubmittedAt()).isEqualTo(NOW.plus(Duration.ofMinutes(5))); + assertThat(again).isEqualTo(resubmitted); + // the onboarding started with the first submit, and is not announced again + assertThat(events.started).hasSize(1); + assertThat(reviewed).containsExactly(asked.id()); + assertThat(reviewedAgain).containsExactly(asked.id()); + problem(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, + () -> service.replace(ACME, asked.id(), resubmitted.version(), resubmitted.data())); + } + + @Test + void aRequestAskedForChanges_isCheckedAgainWhenSubmittedAgain_butNotAgainstTheQuota() { + // with it, the connector's 10 requests awaiting a decision + IntStream.range(0, DefaultOnboardingRequestService.MAX_AWAITING_DECISION - 1).forEach(i -> { + var draft = service.create(ACME, withApplicantReference(data("did:web:acme-" + i + ".example.com"), "ref-" + i), + null).request(); + submit(service.addDocument(ACME, draft.id(), draft.version(), gtc()).request()); + }); + var asked = askForChanges(submit(complete(ACME))); + var gtcId = asked.document(DocumentType.GTC, null).orElseThrow().documentId(); + var withoutGtc = service.removeDocument(ACME, asked.id(), gtcId, asked.version()); + + var refusal = problem(ProblemCode.ONBOARDING_REQUEST_SUBMIT_REJECTED, + () -> service.submit(ACME, asked.id(), withoutGtc.version(), null)); + + assertThat(refusal.errors()).extracting(FieldError::field).containsExactly("documents"); + assertThat(service.get(ACME, asked.id()).status()).isEqualTo(OnboardingStatus.CHANGES_REQUESTED); + assertThat(reviewedAgain).isEmpty(); + + var completed = service.addDocument(ACME, asked.id(), withoutGtc.version(), gtc()).request(); + assertThat(service.submit(ACME, asked.id(), completed.version(), null).status()) + .isEqualTo(OnboardingStatus.UNDER_REVIEW); + } + + @Test + void theFirstSubmit_sentAgainAfterTheReviewAskedForChanges_isNotTheAnswerToIt() { + var draft = complete(ACME); + var submitted = service.submit(ACME, draft.id(), draft.version(), "submit-1"); + var asked = askForChanges(submitted); + + // the response of the first submit was lost: sent again, under its key and the draft's version + var replayed = service.submit(ACME, draft.id(), draft.version(), "submit-1"); + + assertThat(replayed).isEqualTo(asked); + assertThat(reviewedAgain).isEmpty(); + // the answer names the request's current version — under the same key or another + assertThat(service.submit(ACME, draft.id(), asked.version(), "submit-1").status()) + .isEqualTo(OnboardingStatus.UNDER_REVIEW); + assertThat(reviewedAgain).containsExactly(draft.id()); + } + @Test void aSubmitKey_usedForAnotherRequest_isRefused() { var first = complete(ACME); diff --git a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequestTest.java b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequestTest.java index c9290fe..44ebc55 100644 --- a/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequestTest.java +++ b/dx-onboarding-api/src/test/java/com/metaform/dxonboarding/domain/model/onboarding/OnboardingRequestTest.java @@ -3,6 +3,7 @@ import com.metaform.dxonboarding.domain.ProblemCode; import java.time.Instant; import java.time.LocalDate; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -232,4 +233,57 @@ void theReviewsDecision_makesNewVersionsToo() { assertThat(approved.legalEntityId()).isEqualTo("DX-12345678"); assertThat(approved.holdsLegalEntityId()).isTrue(); } + + @Test + void aRequestAskedForChanges_isCorrectedLikeADraft_andSubmittedAgain() { + var gtcId = UUID.randomUUID(); + var submitted = draft(data("did:web:acme.example.com")).withDocument(gtcId, gtc(), NOW).submitted(NOW, "key-1"); + var asked = ChangeRequest.of(LATER, "The GTC copy is not signed", + List.of(new ChangeRequest.Item(null, DocumentType.GTC, "signature missing"))); + + var underReview = submitted.underReview(); + var changesRequested = underReview.changesRequested(asked); + + assertThat(underReview.status()).isEqualTo(OnboardingStatus.UNDER_REVIEW); + assertThat(underReview.changeRequest()).isNull(); + assertThat(changesRequested.status()).isEqualTo(OnboardingStatus.CHANGES_REQUESTED); + assertThat(changesRequested.changeRequest()).isEqualTo(asked); + assertThat(changesRequested.version()).isEqualTo(submitted.version() + 2); + + var corrected = changesRequested + .withData(Requests.withApplicantReference(changesRequested.data(), "ref-2"), LATER) + .withReplacedFile(gtcId, null, pdfFile("gtc-signed.pdf"), LATER) + .withDocument(UUID.randomUUID(), other("Insurance"), LATER); + + // corrected like a draft, yet still the review's: it keeps its status until submitted again + assertThat(corrected.status()).isEqualTo(OnboardingStatus.CHANGES_REQUESTED); + assertThat(corrected.version()).isEqualTo(changesRequested.version() + 3); + assertThat(corrected.document(gtcId).orElseThrow().filename()).isEqualTo("gtc-signed.pdf"); + assertThat(corrected.changeRequest()).isEqualTo(asked); + + var resubmittedAt = LATER.plusSeconds(60); + var resubmitted = corrected.resubmitted(resubmittedAt, null); + + assertThat(resubmitted.status()).isEqualTo(OnboardingStatus.UNDER_REVIEW); + assertThat(resubmitted.version()).isEqualTo(corrected.version() + 1); + assertThat(resubmitted.submittedAt()).isEqualTo(NOW); + assertThat(resubmitted.changeRequest().resubmittedAt()).isEqualTo(resubmittedAt); + assertThat(resubmitted.changeRequest().comment()).isEqualTo("The GTC copy is not signed"); + assertThat(resubmitted.replay().submitKey()).isEqualTo("key-1"); + assertThat(corrected.resubmitted(resubmittedAt, "key-2").replay().submitKey()).isEqualTo("key-2"); + // back with the review + problem(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, () -> resubmitted.withData(resubmitted.data(), LATER)); + } + + @Test + void onlyADraft_canBeDeleted_andOnlyARequestAskedForChangesSubmittedAgain() { + var submitted = draft(data("did:web:acme.example.com")).submitted(NOW, null); + var changesRequested = submitted.underReview().changesRequested(ChangeRequest.of(LATER, "Please confirm", null)); + + assertThat(changesRequested.isEditable()).isTrue(); + assertThat(changesRequested.changeRequest().items()).isEmpty(); + problem(ProblemCode.INVALID_ONBOARDING_REQUEST_STATE, changesRequested::requireDraft); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> submitted.resubmitted(LATER, null))) + .isInstanceOf(IllegalStateException.class); + } } diff --git a/membership-hub/README.md b/membership-hub/README.md index 88a4021..15afd73 100644 --- a/membership-hub/README.md +++ b/membership-hub/README.md @@ -14,7 +14,8 @@ its TSP onboarding intake) by combining the two halves the VE deliberately keeps client-credentials against the VE's OSP IdP). For DECADE-X that is the `dx-onboarding-api`, the TSP onboarding intake: an approval registers the credential holder with the IssuerService and has it offer the member its credentials; the hub polls the request's status, and the approval - is the membership's terminal success. + is the membership's terminal success. When the review asks for changes (`CHANGES_REQUESTED`, + which the VE's TSP does once for every request), the hub submits the request again as it is. Everything dataspace-specific sits behind one `DataspaceOnboarding` implementation per dataspace (`adapter/out/onboarding/`): the shape of the request's `registration` object, the diff --git a/membership-hub/src/main/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboarding.java b/membership-hub/src/main/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboarding.java index a93d276..3268cbb 100644 --- a/membership-hub/src/main/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboarding.java +++ b/membership-hub/src/main/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboarding.java @@ -52,7 +52,10 @@ * DECADE-X credentials (DX_LegalEntity_VC, DX_Site_VC) — and carries the participant's DECADE-X-ID; * {@code REJECTED} and {@code APPROVAL_FAILED} (the provisioning after an approval failed, which * nothing retries) map to DECLINED, and every other status — a draft, the steps of the review — to - * PENDING. + * PENDING. One of those steps is the hub's: when the review asks for changes + * ({@code CHANGES_REQUESTED}), the request waits for its applicant to submit it again. The hub has + * nothing to correct — it sent the member's registration as it is, with placeholder documents — so + * it submits the request again, unchanged, the next time it polls. * *

    The TSP assigns every DECADE-X-ID on approval, and the confirmation hands it to the hub, so a * member declares none. A member hosted HERE needs one before that: its deployment reads it (certo @@ -187,11 +190,12 @@ public boolean pollsStatus() { @Override public RegistrationOutcome pollStatus(Membership membership) { - var request = restClient.get() + var read = restClient.get() .uri("/{id}", membership.onboardingProcessId()) .headers(headers -> authorize(headers, membership.did())) .retrieve() - .body(RequestStatus.class); + .toEntity(RequestStatus.class); + var request = read.getBody(); if (request == null || request.status() == null) { throw new IllegalStateException("The DECADE-X onboarding API returned no status for request " + membership.onboardingProcessId()); @@ -207,10 +211,32 @@ public RegistrationOutcome pollStatus(Membership membership) { case "APPROVAL_FAILED" -> new RegistrationOutcome(membership.externalId(), RegistrationOutcome.Status.DECLINED, "Approved, but provisioning the membership failed" + (reason == null ? "" : ": " + reason)); + case "CHANGES_REQUESTED" -> { + resubmit(membership, read.getHeaders().getETag(), request.changeRequest()); + yield new RegistrationOutcome(membership.externalId(), RegistrationOutcome.Status.PENDING, null); + } default -> new RegistrationOutcome(membership.externalId(), RegistrationOutcome.Status.PENDING, null); }; } + /** + * Answers a change request: submits the request again as it is, under the version it was read + * at. A refusal fails this poll — the next one reads the request again and retries. + */ + private void resubmit(Membership membership, String version, ChangeRequest asked) { + var requestId = membership.onboardingProcessId(); + log.info("DECADE-X onboarding request '{}' of '{}': the TSP asks for changes ({}) — nothing to correct, " + + "submitting it again", requestId, membership.externalId(), + asked == null || asked.comment() == null ? "no comment" : asked.comment()); + try { + var submitted = submit(membership.externalId(), membership.did(), requestId, version); + log.info("DECADE-X onboarding request '{}' of '{}' submitted again: {}", requestId, membership.externalId(), + submitted.status()); + } catch (Refusal refusal) { + throw new IllegalStateException(refusal.getMessage(), refusal.getCause()); + } + } + /** Step 1: the draft — or, under the same key, the one an earlier attempt created. */ private Draft create(String externalId, String did, Map request) { var created = call("create the onboarding request", () -> restClient.post() @@ -258,7 +284,11 @@ private String addDocuments(String requestId, String did, List plac return version; } - /** Step 3: the submit, under the version the documents left the draft at. */ + /** + * Step 3: the submit, under the version the documents left the draft at — and again under the + * version a change request left the request at, with the same key: the TSP tells the two apart + * by the version. + */ private RequestStatus submit(String externalId, String did, String requestId, String version) { var submitted = call("submit the onboarding request", () -> restClient.post() .uri("/{id}/submit", requestId) @@ -464,7 +494,13 @@ private record Document(String documentId, String type, String useCaseId) { } @JsonIgnoreProperties(ignoreUnknown = true) - private record RequestStatus(String status, LegalEntity legalEntity, Decision decision) { + private record RequestStatus(String status, LegalEntity legalEntity, Decision decision, + ChangeRequest changeRequest) { + } + + /** What the review asks to change, when the request is {@code CHANGES_REQUESTED}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + private record ChangeRequest(String comment) { } /** @param legalEntityId the participant's DECADE-X-ID, once approved */ diff --git a/membership-hub/src/test/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboardingTest.java b/membership-hub/src/test/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboardingTest.java index dcdc8d6..6e356d8 100644 --- a/membership-hub/src/test/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboardingTest.java +++ b/membership-hub/src/test/java/com/metaform/cxve/hub/adapter/out/onboarding/decadex/DecadeXOnboardingTest.java @@ -465,6 +465,35 @@ void pollStatus_mapsTheTspStatus() { server.verify(); } + @Test + void pollStatus_aRequestAskedForChanges_isSubmittedAgainAsItIs_andStaysPending() { + expect(HttpMethod.GET, API + "/req-5", DID).andRespond(json(HttpStatus.OK, "7", """ + {"id": "req-5", "version": "7", "status": "CHANGES_REQUESTED", + "changeRequest": {"requestedAt": "2026-10-02T10:00:00Z", "comment": "Submit it again", "items": []}}""")); + expect(HttpMethod.POST, API + "/req-5/submit", DID) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer hub-token")) + .andExpect(header(HttpHeaders.IF_MATCH, "\"7\"")) + .andExpect(header(DecadeXOnboarding.IDEMPOTENCY_KEY_HEADER, "dx-submit-ext-1")) + .andRespond(json(HttpStatus.OK, "8", draft("req-5", "UNDER_REVIEW"))); + + assertThat(onboarding.pollStatus(submitted("req-5"))) + .isEqualTo(new RegistrationOutcome("ext-1", RegistrationOutcome.Status.PENDING, null)); + server.verify(); + } + + @Test + void pollStatus_aResubmitTheTspRefuses_failsThePoll_whichIsRetried() { + expect(HttpMethod.GET, API + "/req-5", DID).andRespond(json(HttpStatus.OK, "7", """ + {"id": "req-5", "status": "CHANGES_REQUESTED"}""")); + expect(HttpMethod.POST, API + "/req-5/submit", DID) + .andRespond(problem(HttpStatus.CONFLICT, "version-conflict", "\"detail\": \"read it again\"")); + + assertThatThrownBy(() -> onboarding.pollStatus(submitted("req-5"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("refused to submit the onboarding request (409): read it again"); + server.verify(); + } + @Test void pollStatus_aFailedProvisioningDeclinesTheMembership() { expectStatus("req-4", """