diff --git a/doc/wiki/bpmn-workflows.md b/doc/wiki/bpmn-workflows.md index 5439ea355..3036873a1 100644 --- a/doc/wiki/bpmn-workflows.md +++ b/doc/wiki/bpmn-workflows.md @@ -82,16 +82,63 @@ An exported `.bpmn` is self-contained: all binding configuration, including inpu ## REST Endpoints -`Elsa.Bpmn.Interchange` registers three routes, all under `bpmn/`: +`Elsa.Bpmn.Interchange` registers five routes, all under `bpmn/`: | Method & route | Permission | What it does | | --- | --- | --- | | `POST bpmn/analyze` | `read:workflow-definitions` | Uploads a single `.bpmn` file (multipart) and returns the Info/Degraded/Dropped findings a read would produce, without persisting anything. | | `POST bpmn/import` | `write:workflow-definitions` | Uploads a single `.bpmn` file and persists it as a new or updated workflow definition (as a draft; it is not published). Optional form fields: `DefinitionId` (update an existing definition instead of creating one), `Name`, `ProcessId` (required when the document declares more than one process). | | `GET bpmn/definitions/{definitionId}/export` | `read:workflow-definitions` | Writes the workflow definition's BPMN source back out as `.bpmn` XML. Optional `VersionOptions` query parameter (`Latest`, `Published`, or a specific version), defaulting to `Latest`. | +| `GET bpmn/definitions/{definitionId}/document` | `read:workflow-definitions` | Reads the workflow definition's stored BPMN source with the `Bpmn.Model`/`Bpmn.Interchange` reader and returns the whole `bpmnDefinitions` document as the library's own JSON (payload format `1.0.0`), rather than as `.bpmn` XML. Same refusals as `Export` when the definition was never imported from BPMN or its stored source is stale. Carries an `ETag` response header for the returned revision — see below. | +| `PUT bpmn/definitions/{definitionId}/document` | `write:workflow-definitions` | Accepts a `bpmnDefinitions` JSON document — the shape `GET` on the same route returns — writes it back out as `.bpmn` XML, and runs it through the same path `Import` runs: analyze, capability check, bind, persist as a new draft, refresh the stored source. Never edits a published version in place, exactly like `Import`. Returns the same `Id`/`DefinitionId`/`Version`/`Analysis` shape `Import` returns, plus the new `ETag`. Requires an `If-Match` request header — see below. | Both `Analyze` and `Import` require exactly one uploaded file; zero or more than one returns `400 Bad Request`. +### Optimistic concurrency on the document endpoints + +`GET` and `PUT` on `bpmn/definitions/{definitionId}/document` exchange a strong `ETag`, so a client that reads the +document, and someone else writes the definition before it writes its own edit back, cannot silently overwrite that +intervening write. The `ETag` is a SHA-256 hash of what the definition stores: its BPMN document (the `Bpmn:SourceXml` +custom property), its activity graph, its version and its id. Any write that changes the stored document or the +graph therefore invalidates it — another document `PUT`, a `POST bpmn/import` with the same `DefinitionId`, a save of +the draft from the workflow designer — including when an unpublished draft is saved in place under the same version, +which is the common case. The value is opaque and must be sent back verbatim. + +Because it is derived from content, identical stored content has an identical `ETag`: a `PUT` that writes back +exactly what is stored returns the same `ETag` `GET` did. The first `PUT` after a `.bpmn` upload replaces the +uploaded bytes with the writer's own rendering of the same document, so the stored document, and with it the `ETag`, +changes once even when nothing was edited. A save that changes only the definition's other properties — its name, +description or variables, say — leaves the document and the graph untouched and does not change the `ETag`; a +document `PUT`, like `Import`, resets those properties regardless. + +`PUT` requires an `If-Match` request header carrying the `ETag` a prior `GET` (or `PUT`) returned: + +- **Missing, or the wildcard `*`** — `428 Precondition Required`. Neither says which revision the caller is + replacing (`*` matches whatever is stored), so the endpoint refuses rather than overwrite blindly, before doing any + import work or persisting anything. +- **Anything other than exactly the definition's current `ETag`** — `412 Precondition Failed`, checked before any + import work and before anything is persisted. The comparison is exact: a weak (`W/`) tag or a list of tags never + matches. The definition was written since the caller last read it; `GET` the document again and reapply the edit. +- **Exactly the current `ETag`** — the request proceeds exactly as before, and the response carries the `ETag` of + the draft as this `PUT` stored it. + +### The document endpoints and the JSON payload format + +The `document` GET/PUT pair exists for Studio (W21, part of #7909): Studio holds a BPMN process as the library's own +JSON payload, not as `.bpmn` XML, and editing it — binding a task (W11), moving a shape (W14) — has to write that +JSON back through the same path `Import` uses, or the stored BPMN source drifts out of sync with the definition (see +`BpmnInterchangeDocumentService.SourceVersionCustomPropertyKey`) and `Export` starts refusing with `422`. + +The request and response bodies on both routes are the `bpmnDefinitions` document exactly as `Bpmn.Model` serializes +it: property names as `Bpmn.Model`'s own `[JsonPropertyName]` attributes declare them, and any enum as its underlying +integer — **not** Elsa's own API-wide JSON conventions (which add a string-enum converter these bodies must not go +through). A client reading or writing this JSON should use a plain `System.Text.Json` serializer with default +options, not whatever conventions the rest of the Elsa API uses. + +A document that declares more than one `` is re-imported against the same `processId` it was originally +imported with — recorded on the workflow definition the first time it is imported, whether from `Import` or from a +`document` `PUT`, so an edit to a multi-process document does not have to name the process again on every save. + ### Capability refusal at import A BPMN document can declare behaviour (e.g. certain multi-instance or event-subprocess shapes) that needs a host @@ -105,9 +152,13 @@ still be refused by `Import` on capability grounds. `Export` does not reconstruct a `.bpmn` document from the Elsa activity graph a definition runs — that would discard everything the reader retained on import (foreign extension elements, foreign attributes, unrecognized children, BPMN -DI layout). Instead, it returns exactly the document `Import` stored at import time. This has a real consequence -until BPMN-aware editing exists in Studio: **edits made through Elsa's own designer, after import, are not reflected -in what `Export` returns.** +DI layout). Instead, it returns exactly the document the most recent successful import stored. This has a real +consequence for one kind of edit: **an edit made through Elsa's own designer, without going back through BPMN, is not +reflected in what `Export` returns** — the graph moves on, but the stored source still describes the document as it +stood before that edit. + +An edit made through the `document` `PUT` endpoint above is different: it re-imports, so the stored source and the +graph move together, and **`Export` reflects the edit immediately afterward.** `Export` also refuses outright, with `422 Unprocessable Entity`, rather than silently returning a stale or wrong document, in two situations: diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs new file mode 100644 index 000000000..0cf102f85 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs @@ -0,0 +1,30 @@ +using Bpmn.Semantics; +using Elsa.Bpmn.Interchange.Services; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; + +/// +/// Formats a into the one error message every endpoint that imports a BPMN +/// document — Import and the document Put endpoint — reports it as. +/// +/// +/// carries as a single +/// flat list, already unioned across every missing capability — it does not say which element drove which capability +/// (unlike BpmnCapabilityRequirements.DrivingElementIds, which is per-capability, but that type is gone by the +/// time an endpoint's catch clause sees the exception). Attributing the full, unioned list to each capability +/// individually would put elements next to a capability they may have nothing to do with, so this reports the +/// missing capabilities together with the combined element list once, rather than repeating it. +/// +internal static class BpmnCapabilityErrorFormatter +{ + /// The message an endpoint reports for . + public static string Format(BpmnCapabilityException exception) + { + var missingCapabilities = string.Join(", ", BpmnInterchangeDocumentService.IndividualCapabilities.Where(capability => exception.Missing.HasFlag(capability))); + var elementIds = string.Join(", ", exception.DrivingElementIds); + + return + $"This deployment does not declare the following BPMN host capabilities the document requires: {missingCapabilities}. " + + $"Offending elements (combined across all missing capabilities above, not attributable to any one of them): {elementIds}."; + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnDocumentJsonOptions.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnDocumentJsonOptions.cs new file mode 100644 index 000000000..6e8393e09 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnDocumentJsonOptions.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using Bpmn.Model; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; + +/// +/// The the bpmn/definitions/{definitionId}/document endpoints read and +/// write a document with. +/// +/// +/// Bpmn.Model declares every serialized property name explicitly through [JsonPropertyName] — see +/// — and carries no [JsonConverter] of its own, so its wire shape is +/// plain defaults: camelCase names exactly as declared, and any enum +/// as its underlying integer. Elsa's own API-wide serializer (Elsa.Workflows.Serialization.Serializers.ApiSerializer, +/// reached through FastEndpoints' configured IApiSerializer) adds a JsonStringEnumConverter and several +/// other converters of its own, none of which this library's schema expects. Letting the document body go through +/// that serializer, the way an ordinary FastEndpoints request or response DTO does, would silently write a shape +/// Bpmn.Model's own reader does not agree is the payload format it published — hence the document endpoints +/// binding and writing the body explicitly with this instance instead of a request/response DTO. +/// +internal static class BpmnDocumentJsonOptions +{ + /// Plain defaults, scoped to the document endpoints only. + public static readonly JsonSerializerOptions Value = new(); +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs new file mode 100644 index 000000000..ab89bb4d2 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs @@ -0,0 +1,39 @@ +using Bpmn.Interchange; +using Elsa.Bpmn.Interchange.Exceptions; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; + +/// +/// The exception-to-status-code mapping shared by every endpoint that reads a workflow definition's stored BPMN +/// source back out through BpmnInterchangeDocumentServiceExport and the document Get +/// endpoint — refusing the same "missing" and "stale" cases the same way. +/// +internal static class BpmnExportExceptionCascade +{ + /// + /// Runs , reporting the shared error response through + /// and for whichever exception it throws. + /// + public static async Task RunAsync( + Func sendResponse, + Action addError, + Func sendErrorsAsync, + CancellationToken cancellationToken) + { + try + { + await sendResponse(); + } + catch (BpmnExportUnavailableException exception) + { + addError(exception.Message); + await sendErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken); + } + catch (BpmnInterchangeException exception) + { + addError(exception.Message); + await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + } + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs new file mode 100644 index 000000000..f169c0a88 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs @@ -0,0 +1,64 @@ +using Bpmn.Interchange; +using Bpmn.Semantics; +using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Bpmn.Interchange.Services; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; + +/// +/// The exception-to-status-code mapping shared by every endpoint that imports a BPMN document through +/// BpmnInterchangeDocumentServiceImport and the document Put endpoint — plus the identical +/// handling both give a result whose ImportResult did not succeed. +/// +internal static class BpmnImportExceptionCascade +{ + /// + /// Runs , reporting the shared error response for whichever exception it throws, or for + /// an unsuccessful , through and + /// . Returns null in every case that already sent a response; the caller + /// sends its own success response otherwise. + /// + public static async Task RunAsync( + Func> import, + Action addError, + Func sendErrorsAsync, + CancellationToken cancellationToken) + { + BpmnDocumentImportResult result; + + try + { + result = await import(); + } + catch (BpmnInterchangeException exception) + { + addError(exception.Message); + await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return null; + } + catch (BpmnBindingException exception) + { + addError(exception.Message); + await sendErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken); + return null; + } + catch (BpmnCapabilityException exception) + { + addError(BpmnCapabilityErrorFormatter.Format(exception)); + await sendErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken); + return null; + } + + if (!result.ImportResult.Succeeded) + { + foreach (var validationError in result.ImportResult.ValidationErrors) + addError(validationError.Message); + + await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return null; + } + + return result; + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/BpmnDocumentETag.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/BpmnDocumentETag.cs new file mode 100644 index 000000000..61435c101 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/BpmnDocumentETag.cs @@ -0,0 +1,69 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Elsa.Bpmn.Interchange.Services; +using Elsa.Extensions; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document; + +/// +/// The strong ETag the document Get and Put endpoints exchange for optimistic concurrency: a +/// SHA-256 hash over the stored workflow definition's id, its version, its BPMN source +/// () and its serialized activity graph +/// (). +/// +/// +/// +/// Content-derived rather than a stored counter, because no counter survives every writer. An unpublished draft is +/// edited in place — same row, same version — and both +/// (behind Import and the document Put) and the workflow-definition save endpoint the designer uses +/// replace CustomProperties wholesale with the caller's, so a counter kept there is wiped, or carried forward +/// unchanged, by exactly the writes it would have to record. What those writes do change is the content: a document +/// Put or an Import rewrites the stored source, a designer save rewrites the graph, and a save that drops +/// the source hashes differently from one that keeps it. The id and version tie the value to one stored row, so a new +/// draft version of identical content still gets a different one. +/// +/// +/// Identical stored content yields an identical ETag, which is what a strong validator means — it names a +/// representation — so a Put that writes back exactly what is stored returns the value Get did, having +/// overwritten nothing. Every input is length-prefixed and an absent one is marked distinctly from an empty one, so +/// no two different sets of inputs feed the hash the same bytes. The value is opaque to clients, which must send it +/// back verbatim. +/// +/// +internal static class BpmnDocumentETag +{ + /// Computes the quoted strong ETag for as it is stored. + public static string From(WorkflowDefinition definition) + { + var sourceXml = definition.CustomProperties.TryGetValue(BpmnInterchangeDocumentService.SourceXmlCustomPropertyKey, out var xml) ? xml : null; + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + Append(hash, definition.Id); + Append(hash, definition.Version.ToString(CultureInfo.InvariantCulture)); + Append(hash, sourceXml); + Append(hash, definition.StringData); + + return $"\"{Convert.ToHexString(hash.GetHashAndReset())}\""; + } + + private static void Append(IncrementalHash hash, string? value) + { + Span header = stackalloc byte[5]; + + if (value is null) + { + header[0] = 0; + hash.AppendData(header[..1]); + return; + } + + var bytes = Encoding.UTF8.GetBytes(value); + header[0] = 1; + BinaryPrimitives.WriteInt32BigEndian(header[1..], bytes.Length); + hash.AppendData(header); + hash.AppendData(bytes); + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs new file mode 100644 index 000000000..0b3cf843d --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs @@ -0,0 +1,66 @@ +using System.Net.Mime; +using System.Text.Json; +using Elsa.Authorization; +using Bpmn.Model; +using Elsa.Abstractions; +using Elsa.Bpmn.Interchange.Endpoints.Bpmn; +using Elsa.Bpmn.Interchange.Services; +using Elsa.Common.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.Get; + +/// +/// Reads a stored workflow definition's BPMN source back out as the library's own bpmnDefinitions JSON +/// document — the shape Endpoints.Bpmn.Document.Put.Put accepts back — rather than as .bpmn XML. +/// +/// +/// A thin wrapper over . Studio never holds the whole +/// document as JSON; the round trip this endpoint and Put exist for is what lets an edit made there — a +/// binding (W11), a moved shape (W14) — be written back without a client-side reimplementation of +/// BpmnXmlWriter. Same missing/stale-source refusals as Export; see +/// 's remarks for what each means. +/// +/// The response body is written with , not through FastEndpoints' configured +/// serializer — see that type's remarks for why the two disagree on shape. +/// +/// +[UsedImplicitly] +internal sealed class Get(IWorkflowDefinitionStore store, BpmnInterchangeDocumentService documentService) : ElsaEndpointWithoutRequest +{ + /// + public override void Configure() + { + Get("bpmn/definitions/{definitionId}/document"); + RequirePermission(Elsa.Bpmn.Interchange.Permissions.BpmnPermissions.Definitions, CoreVerbs.View); + } + + /// + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var definitionId = Route("definitionId")!; + var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); + var definition = await store.FindAsync(filter, cancellationToken); + + if (definition == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + + await BpmnExportExceptionCascade.RunAsync( + async () => + { + var document = documentService.ReadDocument(definition); + var json = JsonSerializer.Serialize(document, BpmnDocumentJsonOptions.Value); + HttpContext.Response.Headers.ETag = BpmnDocumentETag.From(definition); + await Send.StringAsync(json, contentType: MediaTypeNames.Application.Json, cancellation: cancellationToken); + }, + message => AddError(message), + Send.ErrorsAsync, + cancellationToken); + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs new file mode 100644 index 000000000..e77bdcc38 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using Elsa.Authorization; +using Bpmn.Model; +using Elsa.Abstractions; +using Elsa.Bpmn.Interchange.Endpoints.Bpmn; +using Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import; +using Elsa.Bpmn.Interchange.Services; +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.Put; + +/// +/// Accepts the whole bpmnDefinitions JSON document for an existing workflow definition, writes it back out as +/// BPMN 2.0 XML, and imports it through the same path Endpoints.Bpmn.Import.Import runs — analyze, capability +/// check, bind, persist as a draft, refresh the stored source. A published version is never edited in place: like +/// Import, this always produces a new draft. +/// +/// +/// A thin wrapper over . The request body is bound as +/// a raw string and deserialized explicitly with , not through FastEndpoints' +/// configured serializer — see that type's remarks for why the two disagree on shape, and +/// Endpoints.Bpmn.Document.Get.Get for the read side of this round trip. +/// +[UsedImplicitly] +internal sealed class Put(IWorkflowDefinitionStore store, BpmnInterchangeDocumentService documentService) : ElsaEndpointWithoutRequest +{ + /// + public override void Configure() + { + Put("bpmn/definitions/{definitionId}/document"); + RequirePermission(Elsa.Bpmn.Interchange.Permissions.BpmnPermissions.Definitions, CoreVerbs.Write); + } + + /// + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var definitionId = Route("definitionId")!; + var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); + var definition = await store.FindAsync(filter, cancellationToken); + + if (definition == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + + // Optimistic concurrency: a client that GETs the document, then PUTs it back after someone else has written the + // definition in between, must not silently replace that intervening write. The client is required to carry the + // ETag its GET returned as If-Match. A missing header cannot express "I know what I'm overwriting" at all, and + // neither can "*", which matches whatever is stored — so both are refused as 428 rather than honoured. Anything + // else must be exactly the current strong ETag (a weak W/ tag or a list never is), or it proves the client's copy + // is no longer current. All of this is checked before any import work runs or anything is persisted. + var ifMatch = HttpContext.Request.Headers.IfMatch.ToString().Trim(); + + if (ifMatch is "" or "*") + { + AddError("An If-Match header carrying the ETag from a prior GET of this document is required to PUT it back, so an intervening edit is not silently overwritten. The wildcard \"*\" is not accepted."); + await Send.ErrorsAsync(StatusCodes.Status428PreconditionRequired, cancellationToken); + return; + } + + if (!string.Equals(ifMatch, BpmnDocumentETag.From(definition), StringComparison.Ordinal)) + { + AddError("The workflow definition has been written since the ETag in If-Match was issued. GET the document again, reapply the edit, and PUT it with the new ETag."); + await Send.ErrorsAsync(StatusCodes.Status412PreconditionFailed, cancellationToken); + return; + } + + string body; + + using (var reader = new StreamReader(HttpContext.Request.Body)) + body = await reader.ReadToEndAsync(cancellationToken); + + BpmnDefinitions? document; + + try + { + document = JsonSerializer.Deserialize(body, BpmnDocumentJsonOptions.Value); + } + catch (JsonException exception) + { + AddError($"The request body is not a valid BPMN document: {exception.Message}"); + await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return; + } + + if (document is null) + { + AddError("The request body must be a BPMN document, not JSON null."); + await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return; + } + + // The process this definition was imported from, when the document that produced it declared more than one: + // reused here so a multi-process document keeps importing the same process on every edit, without asking the + // caller to say so again. See BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey. + var processId = definition.CustomProperties.TryGetValue(BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey, out var storedProcessId) + ? storedProcessId + : null; + + var result = await BpmnImportExceptionCascade.RunAsync( + () => documentService.ImportDocumentAsync(document, definitionId, processId, cancellationToken), + message => AddError(message), + Send.ErrorsAsync, + cancellationToken); + + if (result is null) + return; + + var persisted = result.ImportResult.WorkflowDefinition; + + // The definition exactly as ImportDocumentAsync's final save wrote it, so the ETag names the state this PUT + // produced. Reloading it from the store instead could pick up a write that landed after that save and hand the + // client a validator for content it never saw, letting its next PUT overwrite that write silently. + HttpContext.Response.Headers.ETag = BpmnDocumentETag.From(persisted); + + await Send.OkAsync(new Response + { + Id = persisted.Id, + DefinitionId = persisted.DefinitionId, + Version = persisted.Version, + Analysis = BpmnImportAnalysisModel.From(result.Analysis) + }, cancellationToken); + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs index db6cb3144..fc141c662 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs @@ -1,7 +1,6 @@ using Elsa.Authorization; -using Bpmn.Interchange; using Elsa.Abstractions; -using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Bpmn.Interchange.Endpoints.Bpmn; using Elsa.Bpmn.Interchange.Services; using Elsa.Common.Models; using Elsa.Workflows.Management; @@ -64,20 +63,14 @@ internal sealed class Export(IWorkflowDefinitionStore store, BpmnInterchangeDocu return; } - try - { - var bytes = documentService.Export(definition); - await Send.BytesAsync(bytes, $"{request.DefinitionId}.bpmn", "application/xml", cancellation: cancellationToken); - } - catch (BpmnExportUnavailableException exception) - { - AddError(exception.Message); - await Send.ErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken); - } - catch (BpmnInterchangeException exception) - { - AddError(exception.Message); - await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); - } + await BpmnExportExceptionCascade.RunAsync( + async () => + { + var bytes = documentService.Export(definition); + await Send.BytesAsync(bytes, $"{request.DefinitionId}.bpmn", "application/xml", cancellation: cancellationToken); + }, + message => AddError(message), + Send.ErrorsAsync, + cancellationToken); } } diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs index cef0d1dd5..a1272d0df 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs @@ -1,8 +1,7 @@ using Elsa.Authorization; -using Bpmn.Interchange; using Bpmn.Semantics; using Elsa.Abstractions; -using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Bpmn.Interchange.Endpoints.Bpmn; using Elsa.Bpmn.Interchange.Services; using JetBrains.Annotations; using Microsoft.AspNetCore.Http; @@ -41,39 +40,14 @@ internal sealed class Import(BpmnInterchangeDocumentService documentService) : E var xml = await BpmnUploadedFileReader.ReadTextAsync(Files[0], cancellationToken); - BpmnDocumentImportResult result; + var result = await BpmnImportExceptionCascade.RunAsync( + () => documentService.ImportAsync(xml, request.DefinitionId, request.Name, request.ProcessId, cancellationToken), + message => AddError(message), + Send.ErrorsAsync, + cancellationToken); - try - { - result = await documentService.ImportAsync(xml, request.DefinitionId, request.Name, request.ProcessId, cancellationToken); - } - catch (BpmnInterchangeException exception) - { - AddError(exception.Message); - await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + if (result is null) return; - } - catch (BpmnBindingException exception) - { - AddError(exception.Message); - await Send.ErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken); - return; - } - catch (BpmnCapabilityException exception) - { - AddCapabilityErrors(exception); - await Send.ErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken); - return; - } - - if (!result.ImportResult.Succeeded) - { - foreach (var validationError in result.ImportResult.ValidationErrors) - AddError(validationError.Message); - - await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); - return; - } var definition = result.ImportResult.WorkflowDefinition; @@ -85,22 +59,4 @@ internal sealed class Import(BpmnInterchangeDocumentService documentService) : E Analysis = BpmnImportAnalysisModel.From(result.Analysis) }, cancellationToken); } - - /// - /// carries as a - /// single flat list, already unioned across every missing capability — it does not say which element drove which - /// capability (unlike BpmnCapabilityRequirements.DrivingElementIds, which is per-capability, but that type - /// is gone by the time this catch clause sees the exception). Attributing the full, unioned list to each - /// capability individually would put elements next to a capability they may have nothing to do with, so this - /// reports the missing capabilities together with the combined element list once, rather than repeating it. - /// - private void AddCapabilityErrors(BpmnCapabilityException exception) - { - var missingCapabilities = string.Join(", ", BpmnInterchangeDocumentService.IndividualCapabilities.Where(capability => exception.Missing.HasFlag(capability))); - var elementIds = string.Join(", ", exception.DrivingElementIds); - - AddError( - $"This deployment does not declare the following BPMN host capabilities the document requires: {missingCapabilities}. " - + $"Offending elements (combined across all missing capabilities above, not attributable to any one of them): {elementIds}."); - } } diff --git a/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs b/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs index c89ab5ef5..20f5f3b39 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs @@ -14,7 +14,8 @@ using Elsa.Workflows.Management.Models; namespace Elsa.Bpmn.Interchange.Services; /// -/// The one code path the Analyze, Import and Export endpoints all sit on top of. +/// The one code path the Analyze, Import, Export and document (/) +/// endpoints all sit on top of. /// /// /// @@ -32,12 +33,14 @@ namespace Elsa.Bpmn.Interchange.Services; /// throw away everything the reader retained to get there. /// /// -/// Export is only ever the document as imported, and that is a real limitation, not a detail. Until BPMN-aware -/// editing exists (a Studio concern, out of scope for this program), nothing re-serializes edits made through Elsa's -/// own designer back into BPMN — always returns the source text -/// stored, never a document reflecting what the definition currently is. That is why it -/// refuses outright, rather than returning something, when it cannot prove that text still matches the definition: -/// see . +/// Export is only ever the document as imported — through whichever path last imported it. +/// never reconstructs a document from the Elsa activity tree; it always +/// returns the source text the most recent successful stored. An edit made through Elsa's +/// own designer, without going back through BPMN, is therefore never reflected — the graph moves on but the stored +/// source describes the document as it stood before that edit, which is why staleness has to be provable rather than +/// assumed; see . An edit made through +/// is different: it re-imports, so the stored source and the returned graph both move together, and +/// reflects it immediately afterward. /// /// /// The stored source can go missing or stale after import, and each is refused with its own diagnosis. BPMN @@ -87,6 +90,19 @@ public sealed class BpmnInterchangeDocumentService( /// public const string SourceVersionCustomPropertyKey = "Bpmn:SourceVersion"; + /// + /// The workflow definition custom property records the processId it bound the + /// definition's root scope from, at the moment it stores . + /// + /// + /// A document that declares more than one <process> needs a processId to disambiguate which + /// one a re-import should bind (see ); this is what lets + /// re-import the same process a multi-process document was originally imported + /// from, without asking the caller to say so again on every edit. Recorded unconditionally, including for a + /// single-process document, so this is always derivable the same way rather than only when it happens to matter. + /// + public const string SourceProcessIdCustomPropertyKey = "Bpmn:SourceProcessId"; + /// /// The host capabilities this deployment's BPMN runtime declares. /// @@ -160,6 +176,7 @@ public sealed class BpmnInterchangeDocumentService( var persisted = importResult.WorkflowDefinition; persisted.CustomProperties[SourceXmlCustomPropertyKey] = xml; persisted.CustomProperties[SourceVersionCustomPropertyKey] = persisted.Version; + persisted.CustomProperties[SourceProcessIdCustomPropertyKey] = rootDefinition.ProcessId; await store.SaveAsync(persisted, cancellationToken); } @@ -192,7 +209,61 @@ public sealed class BpmnInterchangeDocumentService( /// source was recorded. /// /// The stored document cannot be read at all. - public byte[] Export(WorkflowDefinition definition) + public byte[] Export(WorkflowDefinition definition) => Export(ResolveSourceXml(definition)); + + /// + /// Resolves the BPMN source a workflow definition was imported from and reads it back as the neutral + /// object model — the same shape accepts back — + /// through the same reader and use, so retained extension + /// elements, foreign attributes and BPMN DI layout are present on the returned document exactly as the reader + /// retained them. Refuses rather than guessing when that source is missing or no longer trustworthy; see this + /// type's remarks for what "missing" and "stale" mean. + /// + /// The workflow definition to read, as read from the store. + /// + /// The definition does not currently carry BPMN source, or it does but the definition has changed since the + /// source was recorded. + /// + /// The stored document cannot be read at all. + public BpmnDefinitions ReadDocument(WorkflowDefinition definition) + { + var xml = ResolveSourceXml(definition); + return reader.Read(xml, new BpmnImportOptions()).Definitions; + } + + /// + /// Accepts the whole document — the shape returns — + /// writes it back out as BPMN 2.0 XML with , and imports the result through + /// , the same path Import runs. Analyze-then-commit sharing this one code path + /// with the read side is what keeps a preview unable to disagree with what this actually persists. + /// + /// The edited document, deserialized through the library's own JSON converters. + /// The workflow definition to update. + /// + /// The process to (re-)bind when the document declares more than one; not needed when it declares exactly one. + /// See for where a caller re-importing an existing definition + /// finds the value that was used the first time. + /// + /// The cancellation token. + /// The document declares more than one process and does not pick one. + /// The document needs a host capability this deployment does not declare. + /// A work binding cannot be turned into an Elsa activity. + public Task ImportDocumentAsync(BpmnDefinitions document, string definitionId, string? processId, CancellationToken cancellationToken) + { + var xml = writer.Write(document); + return ImportAsync(xml, definitionId, name: null, processId, cancellationToken); + } + + /// + /// The BPMN source a workflow definition was imported from, refusing rather than guessing when it is missing or + /// no longer trustworthy. See this type's remarks for what "missing" and "stale" mean and why each gets its own + /// message. + /// + /// + /// The definition does not currently carry BPMN source, or it does but the definition has changed since the + /// source was recorded. + /// + private static string ResolveSourceXml(WorkflowDefinition definition) { if (!definition.CustomProperties.TryGetValue(SourceXmlCustomPropertyKey, out var xml) || string.IsNullOrEmpty(xml)) { @@ -225,7 +296,7 @@ public sealed class BpmnInterchangeDocumentService( + "would silently return a document that is not what this definition currently is."); } - return Export(xml); + return xml; } private static BpmnProcessDefinition ResolveRootDefinition(BpmnDefinitions definitions, string? processId) diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Assets/two-process.bpmn b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Assets/two-process.bpmn new file mode 100644 index 000000000..eea8da088 --- /dev/null +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Assets/two-process.bpmn @@ -0,0 +1,23 @@ + + + + + Flow_1 + + + Flow_1 + + + + + + Flow_2 + + + Flow_2 + + + + diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs index e724d540e..d01caca0f 100644 --- a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs @@ -3,13 +3,16 @@ using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using Elsa; +using Elsa.Bpmn.Activities; using Elsa.Bpmn.Interchange.Features; using Elsa.Bpmn.Interchange.IntegrationTests.Support; using Elsa.Bpmn.Interchange.Services; using Elsa.Common.Models; using Elsa.Extensions; using Elsa.Testing.Shared; +using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Entities; @@ -224,6 +227,342 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) : Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task DocumentGet_OfAMissingDefinition_ReturnsNotFound() + { + var response = await GetAuthenticatedAsync("bpmn/definitions/does-not-exist/document", "workflows/definitions:view"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DocumentGet_OfADefinitionNeverImportedFromBpmn_ReturnsUnprocessableEntity() + { + var definitionId = await CreateNonBpmnDefinitionAsync(); + + var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("does not currently carry BPMN source", body); + } + + [Fact] + public async Task DocumentGet_OfAFreshlyImportedDefinition_ReturnsOkWithTheLibraryFormatDocument() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + + var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(body); + Assert.Contains("order-process", document.RootElement.GetProperty("processes").EnumerateArray().Select(process => process.GetProperty("processId").GetString())); + } + + [Fact] + public async Task DocumentGet_WhenAuthenticatedWithoutTheRequiredPermission_ReturnsForbidden() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + + // Authenticated, but only holds the write permission Put needs, not the read permission Get needs. + var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:write"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DocumentPut_OfAMissingDefinition_ReturnsNotFound() + { + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + var response = await PutAuthenticatedAsync("bpmn/definitions/does-not-exist/document", content, null, "workflows/definitions:write"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DocumentPut_WithoutIfMatch_ReturnsPreconditionRequired() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var versionBeforePut = await LatestVersionOfAsync(definitionId); + + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, null, "workflows/definitions:write"); + + Assert.Equal((HttpStatusCode)428, response.StatusCode); + Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId)); + } + + [Fact] + public async Task DocumentPut_WithAWildcardIfMatch_ReturnsPreconditionRequiredAndOverwritesNothing() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var (_, documentJson) = await GetDocumentAsync(definitionId); + var storedBeforePut = await LatestStoredAsync(definitionId); + + // "*" matches whatever is currently stored, so honouring it would be exactly the blind overwrite the required + // If-Match exists to prevent. + var response = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), "*"); + + Assert.Equal(HttpStatusCode.PreconditionRequired, response.StatusCode); + Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId)); + } + + [Fact] + public async Task DocumentPut_WithTheCurrentIfMatch_ReturnsOkWithANewETagWhenTheContentChanged() + { + var definitionId = await ImportCamundaOrderProcessWrittenBackAsync(); + var (currentETag, documentJson) = await GetDocumentAsync(definitionId); + + var putResponse = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), currentETag); + + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + var newETag = ETagOf(putResponse); + Assert.NotNull(newETag); + Assert.NotEqual(currentETag, newETag); + // The returned ETag names what was stored, so it is exactly what the next GET hands out. + Assert.Equal(newETag, (await GetDocumentAsync(definitionId)).ETag); + } + + [Fact] + public async Task DocumentPut_OfUnchangedContent_ReturnsTheETagTheGetReturned() + { + var definitionId = await ImportCamundaOrderProcessWrittenBackAsync(); + var (currentETag, documentJson) = await GetDocumentAsync(definitionId); + + var putResponse = await PutDocumentAsync(definitionId, documentJson, currentETag); + + // Content-addressed: writing back exactly what is stored overwrites nothing, so the validator stays the same. + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + Assert.Equal(currentETag, ETagOf(putResponse)); + } + + [Fact] + public async Task DocumentPut_AfterAnInterveningDocumentPut_ReturnsPreconditionFailedAndOverwritesNothing() + { + var definitionId = await ImportCamundaOrderProcessWrittenBackAsync(); + var (staleETag, documentJson) = await GetDocumentAsync(definitionId); + var storedBeforeInterveningPut = await LatestStoredAsync(definitionId); + + var interveningPut = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), staleETag); + Assert.Equal(HttpStatusCode.OK, interveningPut.StatusCode); + + // A layout-only edit to an unpublished draft: same row, same version, same activity graph — only the stored + // document moved, so this is the case that proves the document itself is part of the ETag. + var storedAfterInterveningPut = await LatestStoredAsync(definitionId); + Assert.Equal(storedBeforeInterveningPut with { SourceXml = storedAfterInterveningPut.SourceXml }, storedAfterInterveningPut); + Assert.NotEqual(storedBeforeInterveningPut.SourceXml, storedAfterInterveningPut.SourceXml); + + await AssertStalePutIsRefusedAsync(definitionId, documentJson, staleETag); + } + + [Fact] + public async Task DocumentPut_AfterAnInterveningImportIntoTheSameDefinition_ReturnsPreconditionFailedAndOverwritesNothing() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var (staleETag, documentJson) = await GetDocumentAsync(definitionId); + var storedBeforeImport = await LatestStoredAsync(definitionId); + + // A different document declaring the same process, so the stale PUT below would bind cleanly and silently + // replace it if the precondition let it through. + using var content = new MultipartFormDataContent(); + AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn").Replace("Order Handled", "Order Shipped"), "file"); + content.Add(new StringContent(definitionId), "DefinitionId"); + var importResponse = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write"); + Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode); + + // Imported into the unpublished draft in place: same row, same version. + var storedAfterImport = await LatestStoredAsync(definitionId); + Assert.Equal((storedBeforeImport.Id, storedBeforeImport.Version), (storedAfterImport.Id, storedAfterImport.Version)); + + await AssertStalePutIsRefusedAsync(definitionId, documentJson, staleETag); + } + + [Fact] + public async Task DocumentPut_AfterAnInterveningDesignerSaveOfTheDraft_ReturnsPreconditionFailedAndOverwritesNothing() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var (staleETag, documentJson) = await GetDocumentAsync(definitionId); + var storedBeforeSave = await LatestStoredAsync(definitionId); + + await SaveDraftFromTheDesignerAsync(definitionId); + + // Saved in place with every custom property carried forward: same row, same version, same stored document — + // only the activity graph moved, so this is the case that proves the graph itself is part of the ETag. + var storedAfterSave = await LatestStoredAsync(definitionId); + Assert.Equal(storedBeforeSave with { StringData = storedAfterSave.StringData }, storedAfterSave); + Assert.NotEqual(storedBeforeSave.StringData, storedAfterSave.StringData); + + await AssertStalePutIsRefusedAsync(definitionId, documentJson, staleETag); + } + + [Fact] + public async Task DocumentPut_WithMalformedJson_ReturnsBadRequestAndPersistsNoNewDraft() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var versionBeforePut = await LatestVersionOfAsync(definitionId); + + var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + using var content = new StringContent("{ not valid json", Encoding.UTF8, "application/json"); + var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, ETagOf(getResponse), "workflows/definitions:write"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId)); + } + + [Fact] + public async Task DocumentPut_ThatRemovesARequiredActivityBinding_ReturnsUnprocessableEntityAndPersistsNoNewDraft() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var versionBeforePut = await LatestVersionOfAsync(definitionId); + + var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var documentJson = await getResponse.Content.ReadAsStringAsync(); + + // Strips the one the document carries (on "NotifyWarehouse"), reproducing exactly + // what Import itself refuses for unbound-task-process.bpmn: a task-family element the document describes + // but does not say how to perform. + using var editedDocument = JsonDocument.Parse(documentJson); + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + WriteWithoutActivityBindingExtensions(editedDocument.RootElement, writer); + } + + using var putContent = new ByteArrayContent(stream.ToArray()); + putContent.Headers.ContentType = new("application/json"); + + var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write"); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, putResponse.StatusCode); + var body = await putResponse.Content.ReadAsStringAsync(); + Assert.Contains("nothing binds it to an Elsa activity", body); + Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId)); + } + + [Fact] + public async Task DocumentPut_UnchangedDocument_ReturnsOkAndTheSameFindingsAsImport() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + + var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var documentJson = await getResponse.Content.ReadAsStringAsync(); + + using var putContent = new StringContent(documentJson, Encoding.UTF8, "application/json"); + var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write"); + + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + using var putResult = JsonDocument.Parse(await putResponse.Content.ReadAsStringAsync()); + Assert.Equal(definitionId, putResult.RootElement.GetProperty("definitionId").GetString()); + Assert.Contains( + "order-process", + putResult.RootElement.GetProperty("analysis").GetProperty("processIds").EnumerateArray().Select(processId => processId.GetString())); + + var exportResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/export", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, exportResponse.StatusCode); + var exportedXml = await exportResponse.Content.ReadAsStringAsync(); + Assert.Contains("order-process", exportedXml); + } + + [Fact] + public async Task DocumentPut_WhenAuthenticatedWithoutTheRequiredPermission_ReturnsForbidden() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Authenticated, but only holds the read permission Get needs, not the write permission Put needs. + var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, null, "workflows/definitions:view"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DocumentPut_ForASingleProcessDefinitionImportedBeforeSourceProcessIdExisted_ReturnsOk() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + // Simulates a definition imported before ImportAsync started recording SourceProcessIdCustomPropertyKey. + await RemoveSourceProcessIdCustomPropertyAsync(definitionId); + + var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var documentJson = await getResponse.Content.ReadAsStringAsync(); + + using var putContent = new StringContent(documentJson, Encoding.UTF8, "application/json"); + var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write"); + + // A single-process document does not need SourceProcessId to disambiguate anything, so the missing + // property does not stop the edit from succeeding. + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + } + + [Fact] + public async Task DocumentPut_ForATwoProcessDefinitionImportedBeforeSourceProcessIdExisted_ReturnsBadRequestAndPersistsNoNewDraft() + { + var definitionId = await ImportTwoProcessDocumentAsync("first-process"); + // Simulates a definition imported before ImportAsync started recording SourceProcessIdCustomPropertyKey: + // without it, Put has no way to know which of the document's two processes to re-bind. + await RemoveSourceProcessIdCustomPropertyAsync(definitionId); + var versionBeforePut = await LatestVersionOfAsync(definitionId); + + var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var documentJson = await getResponse.Content.ReadAsStringAsync(); + + using var putContent = new StringContent(documentJson, Encoding.UTF8, "application/json"); + var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write"); + + Assert.True((int)putResponse.StatusCode is >= 400 and < 500, $"Expected a 4xx status code, got {(int)putResponse.StatusCode}."); + var body = await putResponse.Content.ReadAsStringAsync(); + Assert.Contains("specify which one to import", body); + Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId)); + } + + /// + /// Re-serializes with every extensionElements array entry named + /// activityBinding (in the elsa: namespace URI) removed, walking the whole document recursively. + /// + private static void WriteWithoutActivityBindingExtensions(JsonElement element, Utf8JsonWriter writer) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in element.EnumerateObject()) + { + writer.WritePropertyName(property.Name); + WriteWithoutActivityBindingExtensions(property.Value, writer); + } + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in element.EnumerateArray().Where(item => !IsActivityBindingExtensionElement(item))) + WriteWithoutActivityBindingExtensions(item, writer); + writer.WriteEndArray(); + break; + default: + element.WriteTo(writer); + break; + } + } + + private static bool IsActivityBindingExtensionElement(JsonElement element) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty("name", out var name) + && name.ValueKind == JsonValueKind.Object + && name.TryGetProperty("localName", out var localName) + && localName.GetString() == "activityBinding" + && name.TryGetProperty("ns", out var ns) + && ns.GetString() == "https://elsaworkflows.io/schemas/bpmn/v1"; + private async Task CreateNonBpmnDefinitionAsync() { using var scope = _app!.Services.CreateScope(); @@ -267,6 +606,149 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) : return await HttpClient.SendAsync(request); } + private async Task PutAuthenticatedAsync(string requestUri, HttpContent content, string? ifMatch, params string[] permissions) + { + using var request = new HttpRequestMessage(HttpMethod.Put, requestUri) { Content = content }; + request.Headers.Add(TestAuthenticationHandler.PermissionHeader, string.Join(",", permissions)); + + if (ifMatch is not null) + request.Headers.Add("If-Match", ifMatch); + + return await HttpClient.SendAsync(request); + } + + /// The ETag a prior document GET or PUT response carried, for use as the next PUT's If-Match. + private static string? ETagOf(HttpResponseMessage response) => response.Headers.ETag?.Tag; + + /// GETs the document of , asserting it succeeded, and returns its ETag and body. + private async Task<(string? ETag, string Json)> GetDocumentAsync(string definitionId) + { + var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (ETagOf(response), await response.Content.ReadAsStringAsync()); + } + + private Task PutDocumentAsync(string definitionId, string documentJson, string? ifMatch) => + PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", new StringContent(documentJson, Encoding.UTF8, "application/json"), ifMatch, "workflows/definitions:write"); + + /// + /// PUTs with and asserts it is refused with 412 and leaves + /// the stored definition exactly as it was. The version alone cannot show that: an unpublished draft is overwritten in + /// place, under the same version, which is precisely the overwrite this is checking did not happen. + /// + private async Task AssertStalePutIsRefusedAsync(string definitionId, string documentJson, string? staleETag) + { + var storedBeforePut = await LatestStoredAsync(definitionId); + + var response = await PutDocumentAsync(definitionId, documentJson, staleETag); + + Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode); + Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId)); + } + + /// Moves the document's first diagram shape to the right — a layout-only edit, the kind W14 makes. + private static string WithFirstShapeMoved(string documentJson) + { + var document = JsonNode.Parse(documentJson)!; + var bounds = document["diagrams"]![0]!["plane"]!["shapes"]![0]!["bounds"]!; + bounds["x"] = bounds["x"]!.GetValue() + 10; + return document.ToJsonString(); + } + + /// + /// Saves the latest draft of the way the workflow-definition save endpoint Studio's + /// designer calls does — , a re-serialized root, then + /// — with the bound activity's text edited and every custom + /// property, the stored BPMN source included, carried forward as Studio sends them back. + /// + private async Task SaveDraftFromTheDesignerAsync(string definitionId) + { + using var scope = _app!.Services.CreateScope(); + var publisher = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + var draft = await publisher.GetDraftAsync(definitionId, VersionOptions.Latest); + Assert.NotNull(draft); + + var root = Assert.IsType(serializer.Deserialize(draft!.StringData!)); + Assert.Single(root.Activities.OfType()).Text = new("Notifying the warehouse, edited in the designer"); + draft.StringData = serializer.Serialize(root); + + await publisher.SaveDraftAsync(draft); + } + + /// Imports camunda-order-process.bpmn through the real endpoint and returns the resulting definitionId. + private async Task ImportCamundaOrderProcessAsync() + { + using var content = new MultipartFormDataContent(); + AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "file"); + var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("definitionId").GetString()!; + } + + /// + /// Imports camunda-order-process.bpmn and writes its document straight back once through the document PUT, so + /// what is stored is the writer's own rendering of it rather than the uploaded bytes. From there only an actual edit + /// changes the stored content, which is what lets a test attribute an ETag change — or its absence — to one write. + /// + private async Task ImportCamundaOrderProcessWrittenBackAsync() + { + var definitionId = await ImportCamundaOrderProcessAsync(); + var (etag, documentJson) = await GetDocumentAsync(definitionId); + var response = await PutDocumentAsync(definitionId, documentJson, etag); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return definitionId; + } + + /// Imports two-process.bpmn, picking , and returns the resulting definitionId. + private async Task ImportTwoProcessDocumentAsync(string processId) + { + using var content = new MultipartFormDataContent(); + AddBpmnFile(content, ReadAsset("two-process.bpmn"), "file"); + content.Add(new StringContent(processId), "ProcessId"); + var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("definitionId").GetString()!; + } + + /// + /// Removes from the latest version of + /// , simulating a definition imported before ImportAsync started recording it. + /// + private async Task RemoveSourceProcessIdCustomPropertyAsync(string definitionId) + { + using var scope = _app!.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); + var definition = await store.FindAsync(filter); + Assert.NotNull(definition); + definition!.CustomProperties.Remove(BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey); + await store.SaveAsync(definition); + } + + private async Task LatestVersionOfAsync(string definitionId) => (await LatestStoredAsync(definitionId)).Version; + + /// + /// A snapshot of the latest version of : its id, version, activity graph and stored BPMN + /// document — everything a document PUT rewrites that the document ETag covers. + /// + private async Task LatestStoredAsync(string definitionId) + { + using var scope = _app!.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); + var definition = await store.FindAsync(filter); + Assert.NotNull(definition); + definition!.CustomProperties.TryGetValue(BpmnInterchangeDocumentService.SourceXmlCustomPropertyKey, out var sourceXml); + return new(definition.Id, definition.Version, definition.StringData, sourceXml); + } + + private sealed record StoredDefinition(string Id, int Version, string? StringData, string? SourceXml); + private sealed class TestAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnDocumentRoundTripTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnDocumentRoundTripTests.cs new file mode 100644 index 000000000..f5cf9f60e --- /dev/null +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnDocumentRoundTripTests.cs @@ -0,0 +1,164 @@ +using System.Text; +using System.Xml.Linq; +using Bpmn.Model; +using Elsa.Bpmn.Interchange.Binding; +using Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding; +using Elsa.Bpmn.Interchange.IntegrationTests.Support; +using Elsa.Bpmn.Interchange.Services; +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Interchange; + +/// +/// The round trip W21 exists for: and +/// — the document endpoints' shared service path — +/// let a document round-trip through JSON without losing what Export already proves survives XML alone. +/// +/// +/// Derives from , not , because building the +/// edit in the second test needs to write a fresh elsa:activityBinding, +/// exactly as already does for the same reason. +/// +public class BpmnDocumentRoundTripTests : BpmnBindingTestBase +{ + private static readonly XNamespace Camunda = BpmnXNamespaces.Camunda; + private static readonly XNamespace Elsa = BpmnXNamespaces.Elsa; + private static readonly XNamespace Di = BpmnXNamespaces.Di; + private static readonly XNamespace Bpmn = BpmnXNamespaces.Bpmn; + + public BpmnDocumentRoundTripTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + DocumentService = Services.GetRequiredService(); + DefinitionStore = Services.GetRequiredService(); + } + + private BpmnInterchangeDocumentService DocumentService { get; } + + private IWorkflowDefinitionStore DefinitionStore { get; } + + [Fact(DisplayName = "Reading a document then posting it back unchanged exports content-equal to the original")] + public async Task ReadDocument_ThenImportDocumentAsyncUnchanged_ExportsContentEqualToTheOriginal() + { + var xml = ReadAsset("camunda-order-process.bpmn"); + var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None); + Assert.True(imported.ImportResult.Succeeded, string.Join("; ", imported.ImportResult.ValidationErrors.Select(error => error.Message))); + + var definitionId = imported.ImportResult.WorkflowDefinition.DefinitionId; + var beforeStored = await FindLatestAsync(definitionId); + var originalExportedXml = Encoding.UTF8.GetString(DocumentService.Export(beforeStored)); + + var document = DocumentService.ReadDocument(beforeStored); + var processId = ProcessIdOf(beforeStored); + + var putResult = await DocumentService.ImportDocumentAsync(document, definitionId, processId, CancellationToken.None); + Assert.True(putResult.ImportResult.Succeeded, string.Join("; ", putResult.ImportResult.ValidationErrors.Select(error => error.Message))); + + var afterStored = await FindLatestAsync(definitionId); + var roundTrippedExportedXml = Encoding.UTF8.GetString(DocumentService.Export(afterStored)); + + AssertContentEquivalent(XDocument.Parse(originalExportedXml), XDocument.Parse(roundTrippedExportedXml)); + } + + [Fact(DisplayName = "Posting a document back with a new bound task added shows the binding on export and leaves everything else unchanged")] + public async Task ImportDocumentAsync_WithANewBoundTaskAdded_ExportsTheAdditionAndLeavesTheRestUnchanged() + { + var xml = ReadAsset("camunda-order-process.bpmn"); + var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None); + Assert.True(imported.ImportResult.Succeeded, string.Join("; ", imported.ImportResult.ValidationErrors.Select(error => error.Message))); + + var definitionId = imported.ImportResult.WorkflowDefinition.DefinitionId; + var stored = await FindLatestAsync(definitionId); + var originalExportedXml = Encoding.UTF8.GetString(DocumentService.Export(stored)); + + var document = DocumentService.ReadDocument(stored); + var process = Assert.Single(document.Processes); + + const string newTaskId = "ArchiveOrder"; + var binding = Format.Write(new WriteLine("Archiving the order")); + var newTask = new BpmnElement(newTaskId, BpmnElementTypes.ServiceTask, name: "Archive Order", extensions: BpmnActivityBindingFormat.Attach(null, binding)); + + var editedProcess = process with { Elements = process.Elements.Append(newTask).ToList() }; + var editedDocument = document with { Processes = [editedProcess] }; + + var processId = ProcessIdOf(stored); + + var putResult = await DocumentService.ImportDocumentAsync(editedDocument, definitionId, processId, CancellationToken.None); + Assert.True(putResult.ImportResult.Succeeded, string.Join("; ", putResult.ImportResult.ValidationErrors.Select(error => error.Message))); + + var updated = await FindLatestAsync(definitionId); + var updatedExportedXml = Encoding.UTF8.GetString(DocumentService.Export(updated)); + + var originalDocument = XDocument.Parse(originalExportedXml); + var updatedDocument = XDocument.Parse(updatedExportedXml); + + // Everything that was there before the edit is still there, unchanged. + AssertContentEquivalent(originalDocument, updatedDocument); + + // The addition shows up. + var addedTask = updatedDocument.Descendants(Bpmn + "serviceTask").Single(element => element.Attribute("id")?.Value == newTaskId); + var addedBinding = addedTask.Descendants(Elsa + "activityBinding").Single(); + Assert.Equal("Elsa.WriteLine", addedBinding.Attribute("activityType")?.Value); + Assert.Contains("Archiving the order", addedBinding.Descendants(Elsa + "input").Single().Value); + } + + /// Everything camunda-order-process.bpmn carries that an edit must not disturb: foreign attributes, foreign extension elements, the elsa: binding on the untouched task, and BPMN DI waypoints. + private static void AssertContentEquivalent(XDocument expected, XDocument actual) + { + var expectedProcess = expected.Descendants(Bpmn + "process").Single(); + var actualProcess = actual.Descendants(Bpmn + "process").Single(); + Assert.Equal(expectedProcess.Attribute("id")?.Value, actualProcess.Attribute("id")?.Value); + Assert.Equal("order-process", expectedProcess.Attribute("id")?.Value); + Assert.Equal(expectedProcess.Attribute(Camunda + "versionTag")?.Value, actualProcess.Attribute(Camunda + "versionTag")?.Value); + + var expectedTask = expected.Descendants(Bpmn + "serviceTask").Single(element => element.Attribute("id")?.Value == "NotifyWarehouse"); + var actualTask = actual.Descendants(Bpmn + "serviceTask").Single(element => element.Attribute("id")?.Value == "NotifyWarehouse"); + Assert.Equal("true", expectedTask.Attribute(Camunda + "asyncBefore")?.Value); + Assert.Equal(expectedTask.Attribute(Camunda + "asyncBefore")?.Value, actualTask.Attribute(Camunda + "asyncBefore")?.Value); + Assert.Equal(expectedTask.Descendants(Bpmn + "documentation").Single().Value, actualTask.Descendants(Bpmn + "documentation").Single().Value); + + var expectedBinding = expectedTask.Descendants(Elsa + "activityBinding").Single(); + var actualBinding = actualTask.Descendants(Elsa + "activityBinding").Single(); + Assert.Equal("Elsa.WriteLine", expectedBinding.Attribute("activityType")?.Value); + Assert.Equal(expectedBinding.Attribute("activityType")?.Value, actualBinding.Attribute("activityType")?.Value); + Assert.Equal(expectedBinding.Descendants(Elsa + "input").Single().Value, actualBinding.Descendants(Elsa + "input").Single().Value); + + var expectedProperty = expected.Descendants(Camunda + "property").Single(); + var actualProperty = actual.Descendants(Camunda + "property").Single(); + Assert.Equal("owner", expectedProperty.Attribute("name")?.Value); + Assert.Equal(expectedProperty.Attribute("name")?.Value, actualProperty.Attribute("name")?.Value); + Assert.Equal(expectedProperty.Attribute("value")?.Value, actualProperty.Attribute("value")?.Value); + + var expectedInputParameter = expected.Descendants(Camunda + "inputParameter").Single(); + var actualInputParameter = actual.Descendants(Camunda + "inputParameter").Single(); + Assert.Equal(expectedInputParameter.Value, actualInputParameter.Value); + + var expectedWaypoints = expected.Descendants(Di + "waypoint").Select(WaypointOf).ToList(); + var actualWaypoints = actual.Descendants(Di + "waypoint").Select(WaypointOf).ToList(); + Assert.Equal(4, expectedWaypoints.Count); + Assert.Equal(expectedWaypoints, actualWaypoints); + } + + private static (string X, string Y) WaypointOf(XElement element) => (element.Attribute("x")!.Value, element.Attribute("y")!.Value); + + /// The processId a document endpoint would reuse, mirroring how Put resolves it from the stored definition. + private static string? ProcessIdOf(WorkflowDefinition definition) => + definition.CustomProperties.TryGetValue(BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey, out var processId) ? processId : null; + + private async Task FindLatestAsync(string definitionId) + { + var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter(); + var definition = await DefinitionStore.FindAsync(filter); + Assert.NotNull(definition); + return definition!; + } + + /// Reads a fixture from the Assets directory shipped alongside this test project. + private static string ReadAsset(string fileName) => BpmnAssetReader.Read(fileName); +} diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnInterchangeDocumentServiceTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnInterchangeDocumentServiceTests.cs index 48fb944f0..29f189541 100644 --- a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnInterchangeDocumentServiceTests.cs +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnInterchangeDocumentServiceTests.cs @@ -1,5 +1,6 @@ using System.Xml.Linq; using Bpmn.Interchange; +using Elsa.Bpmn.Interchange.IntegrationTests.Support; using Elsa.Bpmn.Interchange.Services; using Elsa.Common.Models; using Elsa.Extensions; @@ -15,11 +16,11 @@ namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Interchange; /// public class BpmnInterchangeDocumentServiceTests(ITestOutputHelper testOutputHelper) : BpmnInterchangeTestBase(testOutputHelper) { - private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn"; - private static readonly XNamespace Elsa = "https://elsaworkflows.io/schemas/bpmn/v1"; - private static readonly XNamespace Dc = "http://www.omg.org/spec/DD/20100524/DC"; - private static readonly XNamespace Di = "http://www.omg.org/spec/DD/20100524/DI"; - private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL"; + private static readonly XNamespace Camunda = BpmnXNamespaces.Camunda; + private static readonly XNamespace Elsa = BpmnXNamespaces.Elsa; + private static readonly XNamespace Dc = BpmnXNamespaces.Dc; + private static readonly XNamespace Di = BpmnXNamespaces.Di; + private static readonly XNamespace Bpmn = BpmnXNamespaces.Bpmn; [Fact(DisplayName = "Analyze and Import report the same findings for the same document")] public async Task Analyze_AndImport_ReportTheSameFindings() diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Support/BpmnXNamespaces.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Support/BpmnXNamespaces.cs new file mode 100644 index 000000000..b64828184 --- /dev/null +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Support/BpmnXNamespaces.cs @@ -0,0 +1,16 @@ +using System.Xml.Linq; + +namespace Elsa.Bpmn.Interchange.IntegrationTests.Support; + +/// +/// The XML namespaces every test that parses a BPMN document with needs, kept in one place +/// so camunda-order-process.bpmn's fixtures agree on them everywhere they are asserted against. +/// +internal static class BpmnXNamespaces +{ + public static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn"; + public static readonly XNamespace Elsa = "https://elsaworkflows.io/schemas/bpmn/v1"; + public static readonly XNamespace Dc = "http://www.omg.org/spec/DD/20100524/DC"; + public static readonly XNamespace Di = "http://www.omg.org/spec/DD/20100524/DI"; + public static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL"; +}