xiangning baaff8db63 feat(workflow): 添加自定义函数表单绑定及权限控制功能
- 新增自定义函数与表单定义绑定关系字段 FormDefinitionId
- 实现按钮调用函数时的表单作用域权限校验机制
- 实现按钮提交表单时的作用域权限检查功能
- 添加自定义函数列表按表单定义筛选查询能力
- 新增 RelationPicker 关联选择表单组件支持
- 完善菜单权限体系支持受限但可见菜单配置
- 添加文件消息大小限制验证(最大200MB)
- 优化表单聚合变量同步异常处理机制
- 增加BuildFlow管理后台入口菜单种子数据
- 补充相关单元测试和架构约束测试用例
2026-07-05 09:30:45 +08:00

508 lines
15 KiB
C#

using FluentAssertions;
using Workflow.Application.Form.Schema;
using Xunit;
namespace BuildFlow.Workflow.Tests.Form;
public class SchemaValidatorTests
{
private static readonly HashSet<string> AllowedComponents =
["Input", "InputNumber", "Select", "Radio.Group", "Checkbox.Group", "FormGrid", "Card", "RelationPicker"];
private const string ComputedExpenseSchema = """
{
"type": "object",
"properties": {
"quantity": { "type": "number", "title": "数量", "x-component": "InputNumber" },
"unitPrice": { "type": "number", "title": "单价", "x-component": "InputNumber" },
"total": {
"type": "number",
"title": "总价",
"x-component": "InputNumber",
"x-field-kind": "computed",
"x-derived": {
"mode": "computed",
"writePolicy": "overwrite",
"expression": {
"operator": "multiply",
"left": { "type": "field", "path": "quantity" },
"right": { "type": "field", "path": "unitPrice" }
}
}
}
}
}
""";
[Fact]
public void Validate_NormalizesInputNumberAliasAndExtractsFieldSummary()
{
const string schema = """
{
"type": "object",
"properties": {
"amount": {
"type": "number",
"title": "金额",
"required": true,
"x-component": "Input.Number",
"x-validator": [
{ "type": "min", "value": 0, "message": "不能小于 0" },
{ "type": "max", "value": 9999, "message": "不能大于 9999" }
]
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
result.Fields.Should().ContainSingle();
var field = result.Fields.Single();
field.Path.Should().Be("amount");
field.JsonType.Should().Be("number");
field.Component.Should().Be("InputNumber");
field.Required.Should().BeTrue();
field.Validators.Select(v => v.Type).Should().Contain(["min", "max"]);
}
[Fact]
public void Validate_AcceptsInputNumberWhenAllowedComponentsUseLegacyAlias()
{
const string schema = """
{
"type": "object",
"properties": {
"amount": {
"type": "number",
"title": "金额",
"x-component": "InputNumber"
}
}
}
""";
var allowedComponents = new HashSet<string> { "Input.Number" };
var result = SchemaValidator.Validate(schema, allowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
result.Fields.Single().Component.Should().Be("InputNumber");
}
[Fact]
public void Validate_RejectsInvalidFieldKey()
{
const string schema = """
{
"type": "object",
"properties": {
"123 bad": {
"type": "string",
"title": "坏字段",
"x-component": "Input"
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("字段名") && e.Contains("123 bad"));
}
[Fact]
public void Validate_ExtractsStaticDataSourceOptions()
{
const string schema = """
{
"type": "object",
"properties": {
"gender": {
"type": "string",
"title": "性别",
"required": true,
"x-component": "Select",
"x-data-source": {
"type": "static",
"options": [
{ "label": "男", "value": "male" },
{ "label": "女", "value": "female", "disabled": true }
]
}
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
var field = result.Fields.Single();
field.Options.Should().HaveCount(2);
field.Options[0].Label.Should().Be("男");
field.Options[0].Value.GetString().Should().Be("male");
field.Options[1].Disabled.Should().BeTrue();
}
[Fact]
public void Validate_ConvertsLegacyTopLevelRequiredIntoFieldSummary()
{
const string schema = """
{
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string",
"title": "姓名",
"x-component": "Input"
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
result.Fields.Single().Required.Should().BeTrue();
}
[Fact]
public void Validate_AcceptsValidConditionReaction()
{
const string schema = """
{
"type": "object",
"properties": {
"leaveType": {
"type": "string",
"title": "请假类型",
"x-component": "Select",
"x-reactions": [
{
"type": "condition",
"target": "reason",
"when": { "source": "leaveType", "operator": "eq", "value": "annual" },
"action": "visible"
}
]
},
"reason": {
"type": "string",
"title": "原因",
"x-component": "Input"
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
}
[Fact]
public void Validate_AcceptsValidDataReactionWithLiteralOperand()
{
const string schema = """
{
"type": "object",
"properties": {
"price": {
"type": "number",
"title": "单价",
"x-component": "InputNumber",
"x-reactions": [
{
"type": "data",
"target": "total",
"expression": { "left": "price", "operator": "multiply", "right": "8" }
}
]
},
"total": {
"type": "number",
"title": "总价",
"x-component": "InputNumber"
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
}
[Fact]
public void Validate_RejectsReactionTargetFieldNotFound()
{
const string schema = """
{
"type": "object",
"properties": {
"leaveType": {
"type": "string",
"title": "请假类型",
"x-component": "Select",
"x-reactions": [
{
"type": "condition",
"target": "nonExist",
"when": { "source": "leaveType", "operator": "eq", "value": "annual" },
"action": "visible"
}
]
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("target") && e.Contains("nonExist"));
}
[Fact]
public void Validate_RejectsInvalidReactionOperator()
{
const string schema = """
{
"type": "object",
"properties": {
"leaveType": {
"type": "string",
"title": "请假类型",
"x-component": "Select",
"x-reactions": [
{
"type": "condition",
"target": "reason",
"when": { "source": "leaveType", "operator": "equals", "value": "annual" },
"action": "visible"
}
]
},
"reason": {
"type": "string",
"title": "原因",
"x-component": "Input"
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("when.operator") && e.Contains("equals"));
}
[Fact]
public void Validate_AcceptsRelativeRemoteDataSource()
{
const string schema = """
{
"type": "object",
"properties": {
"user": {
"type": "string",
"title": "用户",
"x-component": "Select",
"x-data-source": {
"type": "remote",
"url": "/forms",
"method": "GET",
"labelField": "name",
"valueField": "id"
}
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
}
[Fact]
public void Validate_RejectsAbsoluteRemoteUrl()
{
foreach (var url in new[] { "http://evil.com/x", "https://evil.com/x", "//evil.com/x", "ftp://x/y" })
{
var schema = "{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\",\"title\":\"用户\",\"x-component\":\"Select\",\"x-data-source\":{\"type\":\"remote\",\"url\":\"" + url + "\",\"method\":\"GET\"}}}}";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse($"url={url} should be rejected");
result.Errors.Should().Contain(e => e.Contains("url") && e.Contains("相对路径"));
}
}
[Fact]
public void Validate_RejectsRemoteDataSourceWithoutUrl()
{
const string schema = """
{
"type": "object",
"properties": {
"user": {
"type": "string",
"title": "用户",
"x-component": "Select",
"x-data-source": {
"type": "remote"
}
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("remote") && e.Contains("url"));
}
[Fact]
public void Validate_FieldPermission_WithValidActions_Passes()
{
const string schema = """
{
"type": "object",
"properties": {
"salary": {
"type": "number", "title": "薪资", "x-component": "InputNumber",
"x-field-permission": {
"直属主管审批": "visible",
"HR审批": "readonly",
"财务审批": "hidden",
"__default__": "hidden"
}
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
}
[Fact]
public void Validate_FieldPermission_WithInvalidAction_Fails()
{
const string schema = """
{
"type": "object",
"properties": {
"salary": {
"type": "number", "x-component": "InputNumber",
"x-field-permission": { "HR审批": "invisible" }
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("字段权限") && e.Contains("invisible"));
}
[Fact]
public void Validate_ExtractsFieldPermissionIntoFieldSummary()
{
const string schema = """
{
"type": "object",
"properties": {
"salary": {
"type": "number", "x-component": "InputNumber",
"x-field-permission": { "HR审批": "hidden" }
}
}
}
""";
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeTrue();
var salary = result.Fields.Should().ContainSingle(f => f.Path == "salary").Subject;
salary.FieldPermission.Should().NotBeNull();
salary.FieldPermission!["HR审批"].Should().Be("hidden");
}
[Fact]
public void Validate_ComputedFieldMetadata_IsValid()
{
var result = SchemaValidator.Validate(ComputedExpenseSchema, AllowedComponents);
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
}
[Fact]
public void Validate_ComputedFieldWithMissingSourcePath_IsInvalid()
{
var schema = ComputedExpenseSchema.Replace("\"path\": \"quantity\"", "\"path\": \"missingQuantity\"");
var result = SchemaValidator.Validate(schema, AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("missingQuantity"));
}
[Fact]
public void Validate_RelationFieldWithoutSourceCode_IsInvalid()
{
var result = SchemaValidator.Validate("""
{
"type": "object",
"properties": {
"supplier": {
"type": "object",
"title": "供应商",
"x-component": "RelationPicker",
"x-field-kind": "relation",
"x-relation": { "idField": "id", "labelField": "name" }
}
}
}
""", AllowedComponents);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("sourceCode"));
}
[Fact]
public void Validate_DefaultComponentsAcceptRelationPicker()
{
var result = SchemaValidator.Validate("""
{
"type": "object",
"properties": {
"supplier": {
"type": "object",
"title": "供应商",
"x-component": "RelationPicker",
"x-field-kind": "relation",
"x-relation": {
"sourceCode": "supplier",
"idField": "id",
"labelField": "name",
"snapshotFields": ["code", "name"]
}
}
}
}
""");
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
result.Fields.Should().ContainSingle(f => f.Path == "supplier");
}
}