- 新增自定义函数与表单定义绑定关系字段 FormDefinitionId - 实现按钮调用函数时的表单作用域权限校验机制 - 实现按钮提交表单时的作用域权限检查功能 - 添加自定义函数列表按表单定义筛选查询能力 - 新增 RelationPicker 关联选择表单组件支持 - 完善菜单权限体系支持受限但可见菜单配置 - 添加文件消息大小限制验证(最大200MB) - 优化表单聚合变量同步异常处理机制 - 增加BuildFlow管理后台入口菜单种子数据 - 补充相关单元测试和架构约束测试用例
411 lines
15 KiB
C#
411 lines
15 KiB
C#
using BuildFlow.Platform.Application.Auth.Queries;
|
||
using FluentAssertions;
|
||
using BuildFlow.Shared.Domain.Common;
|
||
using MediatR;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Workflow.Application.BuildFlow.Buttons.Commands;
|
||
using Workflow.Application.BuildFlow.Buttons.Handlers;
|
||
using Workflow.Application.BuildFlow.Buttons.Services;
|
||
using Workflow.Application.BuildFlow.CustomFunctions.Commands;
|
||
using Workflow.Application.BuildFlow.CustomFunctions.Handlers;
|
||
using Workflow.Application.BuildFlow.CustomFunctions.Services;
|
||
using Workflow.Domain.Entities;
|
||
using Workflow.Domain.Enums;
|
||
using Xunit;
|
||
|
||
namespace BuildFlow.Workflow.Tests.BuildFlow;
|
||
|
||
/// <summary>
|
||
/// 按钮动作链测试,约束按钮配置驱动后端动作执行,而不是前端写死行为。
|
||
/// </summary>
|
||
public class BuildFlowButtonActionTests
|
||
{
|
||
[Fact]
|
||
public async Task CreateButtonDefinition_PersistsActionMetadata()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var handler = new CreateBuildFlowButtonDefinitionCommandHandler(db);
|
||
|
||
var button = await handler.Handle(
|
||
new CreateBuildFlowButtonDefinitionCommand(
|
||
null,
|
||
null,
|
||
null,
|
||
null,
|
||
"submit",
|
||
"提交",
|
||
"send",
|
||
BuildFlowButtonPlacement.FormToolbar,
|
||
"form:submit",
|
||
null,
|
||
null,
|
||
"""{"title":"确认提交?"}""",
|
||
"""[]""",
|
||
10),
|
||
CancellationToken.None);
|
||
|
||
button.Code.Should().Be("submit");
|
||
button.PermissionCode.Should().Be("form:submit");
|
||
button.Status.Should().Be(BuildFlowMetadataStatus.Draft);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PublishButtonDefinition_ShouldMakeButtonExecutableByRuntime()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var button = CreateButton(Guid.NewGuid());
|
||
button.Status = BuildFlowMetadataStatus.Draft;
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new PublishBuildFlowButtonDefinitionCommandHandler(db);
|
||
|
||
var result = await handler.Handle(
|
||
new PublishBuildFlowButtonDefinitionCommand(button.Id),
|
||
CancellationToken.None);
|
||
|
||
result.Status.Should().Be(BuildFlowMetadataStatus.Published);
|
||
(await db.BuildFlowButtons.FindAsync(button.Id))!.Status
|
||
.Should().Be(BuildFlowMetadataStatus.Published);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task DisableButtonDefinition_ShouldRemoveButtonFromRuntime()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var button = CreateButton(Guid.NewGuid());
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new DisableBuildFlowButtonDefinitionCommandHandler(db);
|
||
|
||
var result = await handler.Handle(
|
||
new DisableBuildFlowButtonDefinitionCommand(button.Id),
|
||
CancellationToken.None);
|
||
|
||
result.Status.Should().Be(BuildFlowMetadataStatus.Disabled);
|
||
(await db.BuildFlowButtons.FindAsync(button.Id))!.Status
|
||
.Should().Be(BuildFlowMetadataStatus.Disabled);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_RunFunctionAction_ReturnsFunctionResultAndWritesLog()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var function = await CreatePublishedFunctionAsync(db);
|
||
var button = CreateButton(function.Id);
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db));
|
||
|
||
var result = await executor.ExecuteAsync(
|
||
button.Id,
|
||
"""{"amount":1200}""",
|
||
CancellationToken.None);
|
||
|
||
result.Success.Should().BeTrue();
|
||
result.Results.Should().ContainSingle().Which.ResultJson.Should().Be("\"high\"");
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_SubmitFormThenRunFunction_PersistsFormDataAndContinuesActionChain()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formDefinition = CreatePublishedFormDefinition(Guid.NewGuid());
|
||
db.FormDefinitions.Add(formDefinition);
|
||
var function = await CreatePublishedFunctionAsync(db);
|
||
var button = CreateSubmitAndRunButton(function.Id);
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db));
|
||
|
||
var result = await executor.ExecuteAsync(
|
||
button.Id,
|
||
$$"""
|
||
{
|
||
"formDefinitionId": "{{formDefinition.Id}}",
|
||
"values": { "name": "张三", "amount": 1200 }
|
||
}
|
||
""",
|
||
CancellationToken.None);
|
||
|
||
result.Success.Should().BeTrue();
|
||
result.Results.Should().ContainSingle().Which.ResultJson.Should().Be("\"high\"");
|
||
|
||
var saved = await db.FormData.SingleAsync(f => f.FormDefinitionId == formDefinition.Id);
|
||
saved.InstanceId.Should().NotBe(Guid.Empty);
|
||
saved.DataJson.Should().Be("""{"name":"张三","amount":1200}""");
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_WhenButtonRequiresPermission_RejectsExecutionWithoutPermissionContext()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var function = await CreatePublishedFunctionAsync(db);
|
||
var button = CreateButton(function.Id);
|
||
button.PermissionCode = "form:submit";
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db));
|
||
|
||
var act = () => executor.ExecuteAsync(
|
||
button.Id,
|
||
"""{"amount":1200}""",
|
||
CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<UnauthorizedException>()
|
||
.WithMessage("*form:submit*");
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_WhenButtonRequiresPermissionAndUserHasAccess_ExecutesAction()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var function = await CreatePublishedFunctionAsync(db);
|
||
var button = CreateButton(function.Id);
|
||
button.PermissionCode = "form:submit";
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var sender = new TestSender(["form:submit"]);
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db),
|
||
sender,
|
||
new TestCurrentUserContext("user-1"));
|
||
|
||
var result = await executor.ExecuteAsync(
|
||
button.Id,
|
||
"""{"amount":1200}""",
|
||
CancellationToken.None);
|
||
|
||
result.Success.Should().BeTrue();
|
||
result.Results.Should().ContainSingle().Which.ResultJson.Should().Be("\"high\"");
|
||
sender.RequestedUserId.Should().Be("user-1");
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_WhenActionFails_DoesNotPersistActionLog()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var button = CreateButton(Guid.NewGuid());
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db));
|
||
|
||
var act = () => executor.ExecuteAsync(button.Id, "{}", CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>();
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_ShouldRejectFunctionBoundToAnotherForm()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var buttonFormId = Guid.NewGuid();
|
||
var functionFormId = Guid.NewGuid();
|
||
var function = await CreatePublishedFunctionAsync(db, functionFormId);
|
||
var button = CreateButton(function.Id);
|
||
button.FormDefinitionId = buttonFormId;
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db));
|
||
|
||
var act = () => executor.ExecuteAsync(
|
||
button.Id,
|
||
"""{"amount":1200}""",
|
||
CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("按钮不能调用其他表单的自定义函数");
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||
(await db.CustomFunctionExecutionLogs.CountAsync()).Should().Be(0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_ShouldRejectSubmitFormForAnotherForm()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var buttonFormId = Guid.NewGuid();
|
||
var submittedFormId = Guid.NewGuid();
|
||
db.FormDefinitions.Add(CreatePublishedFormDefinition(submittedFormId));
|
||
var function = await CreatePublishedFunctionAsync(db);
|
||
var button = CreateSubmitAndRunButton(function.Id);
|
||
button.FormDefinitionId = buttonFormId;
|
||
db.BuildFlowButtons.Add(button);
|
||
await db.SaveChangesAsync();
|
||
|
||
var executor = new BuildFlowButtonActionExecutor(
|
||
db,
|
||
new CustomFunctionExecutionService(db));
|
||
|
||
var act = () => executor.ExecuteAsync(
|
||
button.Id,
|
||
$$"""
|
||
{
|
||
"formDefinitionId": "{{submittedFormId}}",
|
||
"values": { "name": "张三", "amount": 1200 }
|
||
}
|
||
""",
|
||
CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("按钮不能提交其他表单的数据");
|
||
(await db.FormData.CountAsync()).Should().Be(0);
|
||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||
(await db.CustomFunctionExecutionLogs.CountAsync()).Should().Be(0);
|
||
}
|
||
|
||
private static async Task<CustomFunction> CreatePublishedFunctionAsync(
|
||
WorkflowDbContext db,
|
||
Guid? formDefinitionId = null)
|
||
{
|
||
var createHandler = new CreateCustomFunctionCommandHandler(db);
|
||
var publishHandler = new PublishCustomFunctionCommandHandler(db);
|
||
var function = await createHandler.Handle(
|
||
new CreateCustomFunctionCommand(
|
||
"button-amount-level",
|
||
"按钮金额分级",
|
||
null,
|
||
"((System.Text.Json.JsonElement)Variables[\"amount\"]).GetDecimal() > 1000 ? \"high\" : \"normal\"",
|
||
null,
|
||
null,
|
||
5000,
|
||
formDefinitionId),
|
||
CancellationToken.None);
|
||
await publishHandler.Handle(new PublishCustomFunctionCommand(function.Id), CancellationToken.None);
|
||
return await db.CustomFunctions.FindAsync(function.Id) ?? throw new InvalidOperationException();
|
||
}
|
||
|
||
private static FormDefinition CreatePublishedFormDefinition(Guid formDefinitionId)
|
||
=> new()
|
||
{
|
||
Id = formDefinitionId,
|
||
Name = "按钮提交表单",
|
||
Code = $"button-submit-{formDefinitionId:N}",
|
||
Version = 1,
|
||
Status = FormStatus.Published,
|
||
SchemaJson = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"title": "姓名",
|
||
"required": true,
|
||
"x-decorator": "FormItem",
|
||
"x-component": "Input"
|
||
},
|
||
"amount": {
|
||
"type": "number",
|
||
"title": "金额",
|
||
"x-decorator": "FormItem",
|
||
"x-component": "InputNumber"
|
||
}
|
||
}
|
||
}
|
||
""",
|
||
};
|
||
|
||
private static BuildFlowButtonDefinition CreateButton(Guid functionId)
|
||
=> new()
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
Code = "submit-and-run",
|
||
Label = "提交并计算",
|
||
Placement = BuildFlowButtonPlacement.FormToolbar,
|
||
Status = BuildFlowMetadataStatus.Published,
|
||
ActionsJson = $$"""
|
||
[
|
||
{
|
||
"type": "runFunction",
|
||
"target": "{{functionId}}",
|
||
"params": { "amount": "$input.amount" },
|
||
"runPolicy": "stopOnFailure"
|
||
}
|
||
]
|
||
""",
|
||
};
|
||
|
||
private static BuildFlowButtonDefinition CreateSubmitAndRunButton(Guid functionId)
|
||
=> new()
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
Code = "submit-form-and-run",
|
||
Label = "提交表单并计算",
|
||
Placement = BuildFlowButtonPlacement.FormToolbar,
|
||
Status = BuildFlowMetadataStatus.Published,
|
||
ActionsJson = $$"""
|
||
[
|
||
{
|
||
"type": "submitForm"
|
||
},
|
||
{
|
||
"type": "runFunction",
|
||
"target": "{{functionId}}",
|
||
"params": { "amount": "$input.values.amount" },
|
||
"runPolicy": "stopOnFailure"
|
||
}
|
||
]
|
||
""",
|
||
};
|
||
|
||
private sealed class TestCurrentUserContext(string userId) : ICurrentUserContext
|
||
{
|
||
public string UserId { get; } = userId;
|
||
|
||
public string IPAddress => "127.0.0.1";
|
||
}
|
||
|
||
private sealed class TestSender(IReadOnlyCollection<string> accessCodes) : ISender
|
||
{
|
||
public string? RequestedUserId { get; private set; }
|
||
|
||
public Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
|
||
{
|
||
if (request is GetAccessCodesQuery query && typeof(TResponse) == typeof(List<string>))
|
||
{
|
||
RequestedUserId = query.UserId;
|
||
return Task.FromResult((TResponse)(object)accessCodes.ToList());
|
||
}
|
||
|
||
throw new NotSupportedException($"Unexpected request {request.GetType().Name}");
|
||
}
|
||
|
||
public Task Send<TRequest>(TRequest request, CancellationToken cancellationToken = default)
|
||
where TRequest : IRequest
|
||
=> throw new NotSupportedException($"Unexpected request {typeof(TRequest).Name}");
|
||
|
||
public Task<object?> Send(object request, CancellationToken cancellationToken = default)
|
||
=> throw new NotSupportedException($"Unexpected request {request.GetType().Name}");
|
||
|
||
public IAsyncEnumerable<TResponse> CreateStream<TResponse>(
|
||
IStreamRequest<TResponse> request,
|
||
CancellationToken cancellationToken = default)
|
||
=> throw new NotSupportedException($"Unexpected stream request {request.GetType().Name}");
|
||
|
||
public IAsyncEnumerable<object?> CreateStream(object request, CancellationToken cancellationToken = default)
|
||
=> throw new NotSupportedException($"Unexpected stream request {request.GetType().Name}");
|
||
}
|
||
}
|