* fix(bpmn): keep a top-level call activity's fire-and-forget flag through the document PUT A call activity's call options (vw:waitForCompletion) live only on its CallProcess work binding, which the bpmnDefinitions document cannot carry, so the document PUT rebuilt every call activity fresh and silently turned a fire-and-forget call into a waiting one. Reuse the stored options for a top-level call activity whose element id and calledElement are unchanged, mirroring how a kept subprocess already carries its own bindings across; a changed calledElement still binds fresh rather than inheriting options authored for a different process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(bpmn): read the stored BPMN source once per document PUT ImportDocumentAsync's two carry-across helpers each independently looked up SourceXmlCustomPropertyKey and parsed it, so every document PUT read and parsed the stored BPMN source twice. Read and parse it once in ImportDocumentAsync and pass the result to both helpers. Also extends the no-op PUT ETag-stability theory to the top-level-call-activity.bpmn fixture, which was not previously covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(bpmn): filter call activities with Where Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1149 lines
59 KiB
C#
1149 lines
59 KiB
C#
using System.Net;
|
|
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;
|
|
using Elsa.Workflows.Memory;
|
|
using Elsa.Workflows.Models;
|
|
using FastEndpoints;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace Elsa.Bpmn.Interchange.IntegrationTests.Endpoints;
|
|
|
|
/// <summary>
|
|
/// HTTP-level coverage for the Analyze/Import/Export endpoints: the wrapper-specific logic this whole program is
|
|
/// named after — multipart file-count validation, exception-to-status-code mapping, and permission gating — none of
|
|
/// which <see cref="Scenarios.Interchange.BpmnInterchangeDocumentServiceTests"/> or
|
|
/// <see cref="Scenarios.Interchange.BpmnExportAvailabilityTests"/> exercise, since those call the service directly.
|
|
/// Follows the precedent in <c>Elsa.Resilience.IntegrationTests.SimulateResponseEndpointTests</c>: a real
|
|
/// <see cref="WebApplication"/> with FastEndpoints and a <see cref="TestServer"/>, so status codes and permission
|
|
/// gating are asserted against real HTTP responses rather than an endpoint invoked in isolation.
|
|
/// </summary>
|
|
[Collection(nameof(BpmnInterchangeEndpointCollection))]
|
|
public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) : IAsyncLifetime
|
|
{
|
|
private WebApplication? _app;
|
|
private bool _wasSecurityEnabled;
|
|
|
|
private HttpClient HttpClient { get; set; } = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
|
|
EndpointSecurityOptions.SecurityIsEnabled = true;
|
|
|
|
var builder = WebApplication.CreateSlimBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
|
|
builder.Services.AddAuthentication(TestAuthenticationHandler.AuthenticationScheme)
|
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(TestAuthenticationHandler.AuthenticationScheme, _ => { });
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddFastEndpoints(o =>
|
|
{
|
|
o.Assemblies = [typeof(BpmnInterchangeFeature).Assembly];
|
|
o.DisableAutoDiscovery = true;
|
|
});
|
|
builder.Services.AddLogging(logging => logging.AddProvider(new XunitLoggerProvider(testOutputHelper)));
|
|
builder.Services.AddElsa(elsa => elsa
|
|
.AddActivitiesFrom<WriteLine>()
|
|
.UseScheduling()
|
|
.UseCSharp(options => options.AllowHostCodeExecution = true)
|
|
.UseJavaScript()
|
|
.UseLiquid()
|
|
.UseWorkflowManagement()
|
|
.UseBpmnInterchange());
|
|
|
|
_app = builder.Build();
|
|
_app.UseAuthentication();
|
|
_app.UseAuthorization();
|
|
_app.UseFastEndpoints();
|
|
|
|
await _app.StartAsync();
|
|
await _app.Services.PopulateRegistriesAsync();
|
|
HttpClient = _app.GetTestClient();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
|
|
HttpClient.Dispose();
|
|
|
|
if (_app != null)
|
|
{
|
|
await _app.StopAsync();
|
|
await _app.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Analyze_WithZeroFiles_ReturnsBadRequest()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
// A well-formed multipart body with no file part: an empty MultipartFormDataContent serializes a section
|
|
// whose Content-Disposition ASP.NET Core's form parser rejects outright, which would fail before the
|
|
// endpoint's own zero-files check ever runs.
|
|
content.Add(new StringContent("value"), "field");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/analyze", content, "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Analyze_WithTwoFiles_ReturnsBadRequest()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "first");
|
|
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "second");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/analyze", content, "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Analyze_WithOneValidFile_ReturnsOkWithAnalysis()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "file");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/analyze", content, "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
using var document = JsonDocument.Parse(body);
|
|
Assert.Contains("order-process", document.RootElement.GetProperty("processIds").EnumerateArray().Select(e => e.GetString()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_OfADocumentWithAnUnboundTask_ReturnsUnprocessableEntity()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("unbound-task-process.bpmn"), "file");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ImportBindingInvalid, CodeOf(body));
|
|
Assert.Contains("nothing binds it to an Elsa activity", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_OfADocumentWithASubprocessNestedInsideASubprocessThatReusesItsParentsId_ReturnsUnprocessableEntityAndTheServerStaysAlive()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("nested-subprocess-duplicate-id.bpmn"), "file");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ImportDuplicateElementId, CodeOf(body));
|
|
Assert.Contains("Outer", body);
|
|
|
|
// elsa-core#8074: before the fix, reading this document overflowed the stack and killed the process, which
|
|
// .NET cannot catch — there would be no HTTP response to assert on at all. Reaching the assertions above already
|
|
// proves the process survived; a further successful request proves the host is still serving requests, too.
|
|
using var followUpContent = new MultipartFormDataContent();
|
|
AddBpmnFile(followUpContent, ReadAsset("camunda-order-process.bpmn"), "file");
|
|
var followUpResponse = await PostAuthenticatedAsync("bpmn/analyze", followUpContent, "workflows/definitions:view");
|
|
Assert.Equal(HttpStatusCode.OK, followUpResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_OfADocumentWithASubprocessReusingItsParentTopLevelProcessesOwnId_ReturnsUnprocessableEntityAndTheServerStaysAlive()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("subprocess-reuses-parent-process-id.bpmn"), "file");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ImportDuplicateElementId, CodeOf(body));
|
|
Assert.Contains("P", body);
|
|
|
|
// elsa-core#8074: a top-level process's own id was never in the pool checked for uniqueness (only its
|
|
// elements were), so a subprocess declared directly inside it that reuses that same id went undetected and
|
|
// overflowed the stack the same way a subprocess nested inside another subprocess does. See the equivalent
|
|
// nested-subprocess test's remarks: reaching the assertions above already proves the process survived; a
|
|
// further successful request proves the host is still serving requests, too.
|
|
using var followUpContent = new MultipartFormDataContent();
|
|
AddBpmnFile(followUpContent, ReadAsset("camunda-order-process.bpmn"), "file");
|
|
var followUpResponse = await PostAuthenticatedAsync("bpmn/analyze", followUpContent, "workflows/definitions:view");
|
|
Assert.Equal(HttpStatusCode.OK, followUpResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_OfADocumentWithTwoTopLevelProcessesSharingAnId_ReturnsUnprocessableEntity()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("two-process-duplicate-id.bpmn"), "file");
|
|
content.Add(new StringContent("shared"), "ProcessId");
|
|
|
|
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ImportDuplicateElementId, CodeOf(body));
|
|
Assert.Contains("shared", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_OfAValidDocument_ReturnsOkAndPersistsADefinition()
|
|
{
|
|
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);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
using var document = JsonDocument.Parse(body);
|
|
Assert.False(string.IsNullOrWhiteSpace(document.RootElement.GetProperty("definitionId").GetString()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_OfAMissingDefinition_ReturnsNotFound()
|
|
{
|
|
var response = await GetAuthenticatedAsync("bpmn/definitions/does-not-exist/export", "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_OfADefinitionNeverImportedFromBpmn_ReturnsUnprocessableEntity()
|
|
{
|
|
var definitionId = await CreateNonBpmnDefinitionAsync();
|
|
|
|
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/export", "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ExportNotImported, CodeOf(body));
|
|
Assert.Contains("does not currently carry BPMN source", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_OfADefinitionWithAChangedGraphAfterADesignerSave_ReturnsUnprocessableEntityCodedAsStale()
|
|
{
|
|
var definitionId = await ImportCamundaOrderProcessAsync();
|
|
await SaveDraftFromTheDesignerAsync(definitionId);
|
|
|
|
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/export", "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ExportSourceStale, CodeOf(body));
|
|
Assert.Contains("has changed since it was imported", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_WithAMalformedVersion_ReturnsBadRequest()
|
|
{
|
|
var response = await GetAuthenticatedAsync("bpmn/definitions/does-not-exist/export?VersionOptions=not-a-number", "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains("not-a-number", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Export_OfAFreshlyImportedDefinition_ReturnsOkWithBpmnXml()
|
|
{
|
|
using var importContent = new MultipartFormDataContent();
|
|
AddBpmnFile(importContent, ReadAsset("camunda-order-process.bpmn"), "file");
|
|
var importResponse = await PostAuthenticatedAsync("bpmn/import", importContent, "workflows/definitions:write");
|
|
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
|
|
|
|
using var importDocument = JsonDocument.Parse(await importResponse.Content.ReadAsStringAsync());
|
|
var definitionId = importDocument.RootElement.GetProperty("definitionId").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 Export_WhenUnauthenticated_ReturnsUnauthorized()
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "bpmn/definitions/does-not-exist/export");
|
|
var response = await HttpClient.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_WhenAuthenticatedWithoutTheRequiredPermission_ReturnsForbidden()
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "file");
|
|
|
|
// Authenticated, but only holds the read permission Export needs, not the write permission Import needs.
|
|
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:view");
|
|
|
|
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.Equal(BpmnErrorCodes.ExportNotImported, CodeOf(body));
|
|
Assert.Contains("does not currently carry BPMN source", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentGet_OfADefinitionWithAChangedGraphAfterADesignerSave_ReturnsUnprocessableEntityCodedAsStale()
|
|
{
|
|
var definitionId = await ImportCamundaOrderProcessAsync();
|
|
await SaveDraftFromTheDesignerAsync(definitionId);
|
|
|
|
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ExportSourceStale, CodeOf(body));
|
|
Assert.Contains("has changed since it was imported", 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(BpmnErrorCodes.DocumentPreconditionRequired, CodeOf(await response.Content.ReadAsStringAsync()));
|
|
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(BpmnErrorCodes.DocumentPreconditionRequired, CodeOf(await response.Content.ReadAsStringAsync()));
|
|
Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPut_WithTheCurrentIfMatch_ReturnsOkWithANewETagWhenTheContentChanged()
|
|
{
|
|
var definitionId = await ImportWrittenBackAsync("camunda-order-process.bpmn");
|
|
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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("camunda-order-process.bpmn")]
|
|
[InlineData("subprocess-boundary-events.bpmn")]
|
|
[InlineData("transaction-compensation.bpmn")]
|
|
[InlineData("nested-subprocesses.bpmn")]
|
|
[InlineData("top-level-call-activity.bpmn")]
|
|
public async Task DocumentPut_OfUnchangedContent_ReturnsTheETagTheGetReturned(string assetFileName)
|
|
{
|
|
// The nested fixtures prove the subprocess bodies a PUT writes back from the stored document come out
|
|
// byte-identical every time, including a multi-instance subprocess, top-level or nested, whose marker the library
|
|
// also retains in the body and would otherwise write one more copy of on every PUT.
|
|
var definitionId = await ImportWrittenBackAsync(assetFileName);
|
|
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 ImportWrittenBackAsync("camunda-order-process.bpmn");
|
|
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 <elsa:activityBinding> 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.Equal(BpmnErrorCodes.ImportBindingInvalid, CodeOf(body));
|
|
Assert.Contains("nothing binds it to an Elsa activity", body);
|
|
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPut_WithARepeatedElementId_ReturnsUnprocessableEntityAndPersistsNoNewDraftAndTheServerStaysAlive()
|
|
{
|
|
var definitionId = await ImportCamundaOrderProcessAsync();
|
|
var versionBeforePut = await LatestVersionOfAsync(definitionId);
|
|
|
|
var (etag, documentJson) = await GetDocumentAsync(definitionId);
|
|
var putResponse = await PutDocumentAsync(definitionId, WithADuplicatedElementId(documentJson), etag);
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, putResponse.StatusCode);
|
|
var body = await putResponse.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ImportDuplicateElementId, CodeOf(body));
|
|
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
|
|
|
|
// See the equivalent Import test's remarks: reaching the assertions above already proves the process
|
|
// survived reading this document; a further successful request proves the host is still serving requests.
|
|
Assert.Equal(HttpStatusCode.OK, (await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view")).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPut_WithATopLevelProcessIdReusingAnExistingSubprocessId_ReturnsUnprocessableEntityAndPersistsNoNewDraftAndTheServerStaysAlive()
|
|
{
|
|
// nested-subprocesses.bpmn already declares a subprocess with id "Outer"; renaming the top-level process's
|
|
// own id to "Outer" reproduces elsa-core#8074's collision through the document PUT, where the nested scope
|
|
// ("Outer"'s stored body) comes not from the edited document but from the definition's already-stored
|
|
// source (see ImportDocumentAsync's remarks on storedNestedScopes).
|
|
var definitionId = await ImportWrittenBackAsync("nested-subprocesses.bpmn");
|
|
var versionBeforePut = await LatestVersionOfAsync(definitionId);
|
|
|
|
var (etag, documentJson) = await GetDocumentAsync(definitionId);
|
|
var putResponse = await PutDocumentAsync(definitionId, WithTopLevelProcessIdReusingASubprocessId(documentJson, "Outer"), etag);
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, putResponse.StatusCode);
|
|
var body = await putResponse.Content.ReadAsStringAsync();
|
|
Assert.Equal(BpmnErrorCodes.ImportDuplicateElementId, CodeOf(body));
|
|
Assert.Contains("Outer", body);
|
|
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
|
|
|
|
// See the equivalent repeated-element-id test's remarks: reaching the assertions above already proves the
|
|
// process survived reading this document; a further successful request proves the host is still serving
|
|
// requests, too.
|
|
Assert.Equal(HttpStatusCode.OK, (await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view")).StatusCode);
|
|
}
|
|
|
|
[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_WithABindingChange_PreservesTheDefinitionsNonBpmnMetadata()
|
|
{
|
|
var definitionId = await ImportCamundaOrderProcessAsync();
|
|
await SetNonBpmnMetadataOnDraftAsync(definitionId);
|
|
var metadataBeforePut = await CaptureMetadataAsync(definitionId);
|
|
|
|
var (etag, documentJson) = await GetDocumentAsync(definitionId);
|
|
var putResponse = await PutDocumentAsync(definitionId, WithNotifyWarehouseTextChanged(documentJson), etag);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
|
|
|
|
// Every field SetNonBpmnMetadataOnDraftAsync set survived the PUT unchanged.
|
|
var metadataAfterPut = await CaptureMetadataAsync(definitionId);
|
|
Assert.Equal(metadataBeforePut, metadataAfterPut);
|
|
|
|
// ...while the graph reflects the binding change the PUT carried.
|
|
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("Notifying the warehouse, rebound via document PUT", exportedXml);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_WithTheSameDefinitionId_StillReplacesTheDefinitionsMetadata()
|
|
{
|
|
var definitionId = await ImportCamundaOrderProcessAsync();
|
|
await SetNonBpmnMetadataOnDraftAsync(definitionId);
|
|
var metadataBeforeImport = await CaptureMetadataAsync(definitionId);
|
|
|
|
// POST bpmn/import with a DefinitionId is a whole-definition import, unlike the document PUT above: it must
|
|
// keep replacing everything SetNonBpmnMetadataOnDraftAsync set, exactly as it did before that PUT preserved
|
|
// it.
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "file");
|
|
content.Add(new StringContent(definitionId), "DefinitionId");
|
|
var importResponse = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
|
|
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
|
|
|
|
var metadataAfterImport = await CaptureMetadataAsync(definitionId);
|
|
Assert.NotEqual(metadataBeforeImport, metadataAfterImport);
|
|
Assert.Equal("Order Process", metadataAfterImport.Name);
|
|
Assert.Null(metadataAfterImport.Description);
|
|
Assert.Equal(string.Empty, metadataAfterImport.VariableSummary);
|
|
Assert.Null(metadataAfterImport.UsableAsActivity);
|
|
Assert.Equal(string.Empty, metadataAfterImport.InputSummary);
|
|
Assert.Equal(string.Empty, metadataAfterImport.OutputSummary);
|
|
Assert.Equal(string.Empty, metadataAfterImport.OutcomeSummary);
|
|
Assert.Null(metadataAfterImport.ToolVersion);
|
|
Assert.False(metadataAfterImport.IsReadonly);
|
|
Assert.Null(metadataAfterImport.CustomPropertyValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentPut_AfterAMetadataOnlySaveCreatesANewDraftFromAPublishedVersion_SucceedsAndRecordsAFreshMarker()
|
|
{
|
|
var definitionId = await ImportCamundaOrderProcessAsync();
|
|
await MarkLatestPublishedAsync(definitionId);
|
|
await RenameLatestDraftThroughTheDesignerAsync(definitionId, "Renamed through the designer");
|
|
|
|
// A metadata-only save through the designer path bumps a published definition to a new draft (N+1) without
|
|
// touching the graph, which is exactly the shape this issue is about: the document GET must not refuse that
|
|
// draft as stale, so the PUT that follows (W11's flow) has an ETag to send at all.
|
|
Assert.Equal(2, await LatestVersionOfAsync(definitionId));
|
|
var (etag, documentJson) = await GetDocumentAsync(definitionId);
|
|
|
|
var putResponse = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), etag);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
|
|
|
|
var stored = await FindLatestDefinitionAsync(definitionId);
|
|
Assert.True(stored.CustomProperties.TryGetValue<int>(BpmnInterchangeDocumentService.SourceVersionCustomPropertyKey, out var sourceVersion));
|
|
Assert.Equal(stored.Version, sourceVersion);
|
|
Assert.True(stored.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceGraphHashCustomPropertyKey, out var sourceGraphHash));
|
|
Assert.False(string.IsNullOrEmpty(sourceGraphHash));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-serializes <paramref name="element"/> with every <c>extensionElements</c> array entry named
|
|
/// <c>activityBinding</c> (in the <c>elsa:</c> namespace URI) removed, walking the whole document recursively.
|
|
/// </summary>
|
|
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<string> CreateNonBpmnDefinitionAsync()
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var store = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
|
|
var definitionId = Guid.NewGuid().ToString();
|
|
|
|
await store.SaveAsync(new WorkflowDefinition
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
DefinitionId = definitionId,
|
|
Name = "Not BPMN",
|
|
Version = 1,
|
|
IsLatest = true,
|
|
MaterializerName = "Json",
|
|
StringData = "{}"
|
|
});
|
|
|
|
return definitionId;
|
|
}
|
|
|
|
private static void AddBpmnFile(MultipartFormDataContent content, string xml, string formFieldName)
|
|
{
|
|
var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(xml));
|
|
fileContent.Headers.ContentType = new("application/xml");
|
|
content.Add(fileContent, formFieldName, $"{formFieldName}.bpmn");
|
|
}
|
|
|
|
private static string ReadAsset(string fileName) => BpmnAssetReader.Read(fileName);
|
|
|
|
private async Task<HttpResponseMessage> PostAuthenticatedAsync(string requestUri, HttpContent content, params string[] permissions)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = content };
|
|
request.Headers.Add(TestAuthenticationHandler.PermissionHeader, string.Join(",", permissions));
|
|
return await HttpClient.SendAsync(request);
|
|
}
|
|
|
|
private async Task<HttpResponseMessage> GetAuthenticatedAsync(string requestUri, params string[] permissions)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
|
request.Headers.Add(TestAuthenticationHandler.PermissionHeader, string.Join(",", permissions));
|
|
return await HttpClient.SendAsync(request);
|
|
}
|
|
|
|
private async Task<HttpResponseMessage> 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);
|
|
}
|
|
|
|
/// <summary>The ETag a prior <c>document</c> GET or PUT response carried, for use as the next PUT's <c>If-Match</c>.</summary>
|
|
private static string? ETagOf(HttpResponseMessage response) => response.Headers.ETag?.Tag;
|
|
|
|
/// <summary>The <c>code</c> field of a coded BPMN error response body (see <see cref="BpmnErrorCodes"/>), or <c>null</c> if it carries none.</summary>
|
|
private static string? CodeOf(string body)
|
|
{
|
|
using var document = JsonDocument.Parse(body);
|
|
return document.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
|
}
|
|
|
|
/// <summary>GETs the document of <paramref name="definitionId"/>, asserting it succeeded, and returns its ETag and body.</summary>
|
|
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<HttpResponseMessage> PutDocumentAsync(string definitionId, string documentJson, string? ifMatch) =>
|
|
PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", new StringContent(documentJson, Encoding.UTF8, "application/json"), ifMatch, "workflows/definitions:write");
|
|
|
|
/// <summary>
|
|
/// PUTs <paramref name="documentJson"/> with <paramref name="staleETag"/> 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.
|
|
/// </summary>
|
|
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(BpmnErrorCodes.DocumentPreconditionFailed, CodeOf(await response.Content.ReadAsStringAsync()));
|
|
Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId));
|
|
}
|
|
|
|
/// <summary>Moves the document's first diagram shape to the right — a layout-only edit, the kind W14 makes.</summary>
|
|
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<double>() + 10;
|
|
return document.ToJsonString();
|
|
}
|
|
|
|
/// <summary>Renames the document's last top-level element to its first element's id, so the two collide (elsa-core#8074).</summary>
|
|
private static string WithADuplicatedElementId(string documentJson)
|
|
{
|
|
var document = JsonNode.Parse(documentJson)!;
|
|
var elements = document["processes"]![0]!["elements"]!.AsArray();
|
|
var firstElementId = elements[0]!["elementId"]!.GetValue<string>();
|
|
elements[^1]!["elementId"] = firstElementId;
|
|
return document.ToJsonString();
|
|
}
|
|
|
|
/// <summary>Renames the document's top-level process id to <paramref name="subprocessId"/>, so it collides with a subprocess that already declares that id (elsa-core#8074).</summary>
|
|
private static string WithTopLevelProcessIdReusingASubprocessId(string documentJson, string subprocessId)
|
|
{
|
|
var document = JsonNode.Parse(documentJson)!;
|
|
document["processes"]![0]!["processId"] = subprocessId;
|
|
return document.ToJsonString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Changes the literal text the <c>NotifyWarehouse</c> task's <c>elsa:activityBinding</c> configures its bound
|
|
/// <see cref="WriteLine"/> with — a real binding change, the kind Elsa Studio's binding UX makes, as opposed to
|
|
/// <see cref="WithFirstShapeMoved"/>'s layout-only edit.
|
|
/// </summary>
|
|
private static string WithNotifyWarehouseTextChanged(string documentJson) =>
|
|
documentJson.Replace("Notifying the warehouse", "Notifying the warehouse, rebound via document PUT");
|
|
|
|
/// <summary>
|
|
/// Renames the latest draft of <paramref name="definitionId"/> and sets a description, a variable, an
|
|
/// activity-usable option, an input, an output, an outcome, a tool version, the read-only flag and a custom
|
|
/// property on it — the non-BPMN metadata a document PUT must leave untouched, set the way Studio's own
|
|
/// definitions API would (<see cref="IWorkflowDefinitionPublisher.GetDraftAsync"/> then
|
|
/// <see cref="IWorkflowDefinitionPublisher.SaveDraftAsync"/>).
|
|
/// </summary>
|
|
private async Task SetNonBpmnMetadataOnDraftAsync(string definitionId)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var publisher = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionPublisher>();
|
|
var draft = await publisher.GetDraftAsync(definitionId, VersionOptions.Latest);
|
|
Assert.NotNull(draft);
|
|
|
|
draft!.Name = "Renamed by the author, not by BPMN";
|
|
draft.Description = "Handles a customer order end to end.";
|
|
draft.Variables = [new Variable<string>("OrderReference", "unset")];
|
|
draft.Options.UsableAsActivity = true;
|
|
draft.Inputs = [new InputDefinition { Name = "CustomerId", Type = typeof(string) }];
|
|
draft.Outputs = [new OutputDefinition { Name = "OrderId", Type = typeof(string) }];
|
|
draft.Outcomes = ["Fulfilled"];
|
|
draft.ToolVersion = new Version(1, 2, 3);
|
|
draft.IsReadonly = true;
|
|
draft.CustomProperties["Custom:Owner"] = "fulfillment-team-lead";
|
|
|
|
await publisher.SaveDraftAsync(draft);
|
|
}
|
|
|
|
/// <summary>A snapshot of everything <see cref="SetNonBpmnMetadataOnDraftAsync"/> sets, for before/after comparison.</summary>
|
|
private sealed record CapturedMetadata(
|
|
string? Name,
|
|
string? Description,
|
|
string VariableSummary,
|
|
bool? UsableAsActivity,
|
|
string InputSummary,
|
|
string OutputSummary,
|
|
string OutcomeSummary,
|
|
Version? ToolVersion,
|
|
bool IsReadonly,
|
|
string? CustomPropertyValue);
|
|
|
|
private async Task<CapturedMetadata> CaptureMetadataAsync(string definitionId)
|
|
{
|
|
var definition = await FindLatestDefinitionAsync(definitionId);
|
|
var variableSummary = string.Join(";", definition.Variables.Select(variable => $"{variable.Name}={variable.Value}"));
|
|
var inputSummary = string.Join(";", definition.Inputs.Select(input => $"{input.Name}:{input.Type}"));
|
|
var outputSummary = string.Join(";", definition.Outputs.Select(output => $"{output.Name}:{output.Type}"));
|
|
var outcomeSummary = string.Join(";", definition.Outcomes);
|
|
definition.CustomProperties.TryGetValue<string>("Custom:Owner", out var customPropertyValue);
|
|
|
|
return new(
|
|
definition.Name,
|
|
definition.Description,
|
|
variableSummary,
|
|
definition.Options.UsableAsActivity,
|
|
inputSummary,
|
|
outputSummary,
|
|
outcomeSummary,
|
|
definition.ToolVersion,
|
|
definition.IsReadonly,
|
|
customPropertyValue);
|
|
}
|
|
|
|
private async Task<WorkflowDefinition> FindLatestDefinitionAsync(string definitionId)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var store = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
|
|
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
|
|
var definition = await store.FindAsync(filter);
|
|
Assert.NotNull(definition);
|
|
return definition!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the latest draft of <paramref name="definitionId"/> the way the workflow-definition save endpoint Studio's
|
|
/// designer calls does — <see cref="IWorkflowDefinitionPublisher.GetDraftAsync"/>, a re-serialized root, then
|
|
/// <see cref="IWorkflowDefinitionPublisher.SaveDraftAsync"/> — with the bound activity's text edited and every custom
|
|
/// property, the stored BPMN source included, carried forward as Studio sends them back.
|
|
/// </summary>
|
|
private async Task SaveDraftFromTheDesignerAsync(string definitionId)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var publisher = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionPublisher>();
|
|
var serializer = scope.ServiceProvider.GetRequiredService<IActivitySerializer>();
|
|
var draft = await publisher.GetDraftAsync(definitionId, VersionOptions.Latest);
|
|
Assert.NotNull(draft);
|
|
|
|
var root = Assert.IsType<BpmnProcess>(serializer.Deserialize(draft!.StringData!));
|
|
Assert.Single(root.Activities.OfType<WriteLine>()).Text = new("Notifying the warehouse, edited in the designer");
|
|
draft.StringData = serializer.Serialize(root);
|
|
|
|
await publisher.SaveDraftAsync(draft);
|
|
}
|
|
|
|
/// <summary>Imports <c>camunda-order-process.bpmn</c> through the real endpoint and returns the resulting <c>definitionId</c>.</summary>
|
|
private Task<string> ImportCamundaOrderProcessAsync() => ImportAssetAsync("camunda-order-process.bpmn");
|
|
|
|
/// <summary>Imports the fixture <paramref name="assetFileName"/> through the real endpoint and returns the resulting <c>definitionId</c>.</summary>
|
|
private async Task<string> ImportAssetAsync(string assetFileName)
|
|
{
|
|
using var content = new MultipartFormDataContent();
|
|
AddBpmnFile(content, ReadAsset(assetFileName), "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()!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Imports the fixture <paramref name="assetFileName"/> 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.
|
|
/// </summary>
|
|
private async Task<string> ImportWrittenBackAsync(string assetFileName)
|
|
{
|
|
var definitionId = await ImportAssetAsync(assetFileName);
|
|
var (etag, documentJson) = await GetDocumentAsync(definitionId);
|
|
var response = await PutDocumentAsync(definitionId, documentJson, etag);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
return definitionId;
|
|
}
|
|
|
|
/// <summary>Imports <c>two-process.bpmn</c>, picking <paramref name="processId"/>, and returns the resulting <c>definitionId</c>.</summary>
|
|
private async Task<string> 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()!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes <see cref="BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey"/> from the latest version of
|
|
/// <paramref name="definitionId"/>, simulating a definition imported before <c>ImportAsync</c> started recording it.
|
|
/// </summary>
|
|
private async Task RemoveSourceProcessIdCustomPropertyAsync(string definitionId)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var store = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes the latest version of <paramref name="definitionId"/> through the real
|
|
/// <see cref="IWorkflowDefinitionPublisher"/>. See <see cref="DefinitionPublishing.PublishLatestAsync"/>.
|
|
/// </summary>
|
|
private async Task MarkLatestPublishedAsync(string definitionId)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var publisher = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionPublisher>();
|
|
await DefinitionPublishing.PublishLatestAsync(publisher, definitionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renames the latest draft of <paramref name="definitionId"/> the way the workflow-definition save endpoint
|
|
/// Studio's designer calls does — <see cref="IWorkflowDefinitionPublisher.GetDraftAsync"/> then
|
|
/// <see cref="IWorkflowDefinitionPublisher.SaveDraftAsync"/> — without touching the activity graph: a
|
|
/// metadata-only save, which carries a published definition to a new draft version without moving the graph the
|
|
/// stored BPMN source describes.
|
|
/// </summary>
|
|
private async Task RenameLatestDraftThroughTheDesignerAsync(string definitionId, string name)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var publisher = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionPublisher>();
|
|
var draft = await publisher.GetDraftAsync(definitionId, VersionOptions.Latest);
|
|
Assert.NotNull(draft);
|
|
draft!.Name = name;
|
|
await publisher.SaveDraftAsync(draft);
|
|
}
|
|
|
|
private async Task<int> LatestVersionOfAsync(string definitionId) => (await LatestStoredAsync(definitionId)).Version;
|
|
|
|
/// <summary>
|
|
/// A snapshot of the latest version of <paramref name="definitionId"/>: its id, version, activity graph and stored BPMN
|
|
/// document — everything a document PUT rewrites that the document ETag covers.
|
|
/// </summary>
|
|
private async Task<StoredDefinition> LatestStoredAsync(string definitionId)
|
|
{
|
|
using var scope = _app!.Services.CreateScope();
|
|
var store = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
|
|
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
|
|
var definition = await store.FindAsync(filter);
|
|
Assert.NotNull(definition);
|
|
definition!.CustomProperties.TryGetValue<string>(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<AuthenticationSchemeOptions> options,
|
|
ILoggerFactory logger,
|
|
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
{
|
|
public const string AuthenticationScheme = "Test";
|
|
public const string PermissionHeader = "X-Test-Permissions";
|
|
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
if (!Request.Headers.TryGetValue(PermissionHeader, out var permissionHeader))
|
|
return Task.FromResult(AuthenticateResult.NoResult());
|
|
|
|
var claims = permissionHeader
|
|
.SelectMany(x => x?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
.Select(x => new Claim(PermissionNames.ClaimType, x))
|
|
.ToList();
|
|
|
|
claims.Add(new Claim(ClaimTypes.NameIdentifier, "test-user"));
|
|
|
|
var identity = new ClaimsIdentity(claims, AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
var ticket = new AuthenticationTicket(principal, AuthenticationScheme);
|
|
|
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
|
}
|
|
}
|
|
}
|
|
|
|
[CollectionDefinition(nameof(BpmnInterchangeEndpointCollection), DisableParallelization = true)]
|
|
public class BpmnInterchangeEndpointCollection;
|