using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Elsa;
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.Activities;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
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;
///
/// 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 or
/// exercise, since those call the service directly.
/// Follows the precedent in Elsa.Resilience.IntegrationTests.SimulateResponseEndpointTests: a real
/// with FastEndpoints and a , so status codes and permission
/// gating are asserted against real HTTP responses rather than an endpoint invoked in isolation.
///
[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(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()
.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);
}
[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.Contains("does not currently carry BPMN source", 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);
}
private async Task CreateNonBpmnDefinitionAsync()
{
using var scope = _app!.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService();
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 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 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 sealed class TestAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder)
{
public const string AuthenticationScheme = "Test";
public const string PermissionHeader = "X-Test-Permissions";
protected override Task 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;