using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
///
/// S7 成品检验单(S7_FQC_INSPBILL,源 qms_qcpp_inspbill)通用审批旁路封堵 · 源码契约测试(S7-FQC-GUARD-1)。
///
/// 背景:守卫名单此前只有 S5_IQC_INSPBILL / S6_PROCESS_INSPECTION / IPQC_INSPECTION,
/// S7 一直在名单外(早前批次显式记为 OUT OF SCOPE)。
///
/// 为什么 S7 的绕过面是**全部**业务语义:本链的 三个回调
/// (OnFlowStarted / OnNodeCompleted / OnFlowCompleted)**全是 no-op**,
/// 业务写入一律由 FqcInspBillFlowService 在引擎之外、同事务内完成。于是通用审批:
/// ① N1 approve → submit-result 从未写 pd/hgsl/bhgsl/clfs 就推到 N2;更严重的是 GW_RESULT
/// 网关按 pd 算 disposition_required,pd 为 null 时算 0 → 不合格单静默绕过 N3 QE 处置直接结束;
/// ② N2 approve → 跳过 TryEnqueueFqcPassOutboxAsync,合格出站 Outbox 永不入队;
/// ③ N2 reject → FlowEngine.Reject 把实例置 Rejected 终态并取消所有待办,而专用 supervisor-reject
/// 是 ReturnToPrev 回 N1 + 同事务复位 qms_fqcbj.FINSPECTSTATUS + 同步 mdp_std_fqc_task;
/// ④ N3 approve → 推到 end 而 ado_s7_fqc_qe_disposition 永不写入,处置轨迹为空且无法补记。
///
/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖
/// SqlSugarRepository<T>,裸 xUnit 进程无法实例化。真实阻断由本批 Runtime R1–R4 覆盖。
///
public class S7FqcGenericApprovalGuardContractTests
{
private const string BizType = "S7_FQC_INSPBILL";
private const string Guard = "EnsureDedicatedEntryOnlyAsync";
private static string FlowTaskService() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
private static string FlowConst() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs"));
private static string FlowService() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs"));
private static string Dict() => Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
// ── 纳入守卫 ──
/// S7 真实 BizType 必须在名单里,且与常量定义一致(不允许两处写不同字面量)。
[Fact]
public void S7BizType_IsInDedicatedEntryGuard()
{
Assert.Contains($"[\"{BizType}\"]", Dict());
Assert.Contains($"public const string BizType = \"{BizType}\"", FlowConst());
}
/// §15 Regression Freeze:新增 S7 不得挤掉既有三条。
[Theory]
[InlineData("S5_IQC_INSPBILL")]
[InlineData("S6_PROCESS_INSPECTION")]
[InlineData("IPQC_INSPECTION")]
public void ExistingGuardedBizTypes_ArePreserved(string bizType)
{
Assert.Contains($"[\"{bizType}\"]", Dict());
}
/// 名单恰好四条 —— 防止本批顺手扩大守卫范围。
[Fact]
public void Guard_CoversExactlyFourBizTypes()
{
Assert.Equal(4, CountOf(Dict(), "\"] = \""));
}
// ── 四个通用入口全覆盖(§14)──
/// approve / reject / batchApprove / batchReject 四个入口都必须先过同一守卫。
[Theory]
[InlineData("_engine.Approve(input.TaskId, input.Comment)")]
[InlineData("_engine.Reject(input.TaskId, input.Comment)")]
public void SingleActions_GuardPrecedesEngineCall(string engineCall)
{
var s = FlowTaskService();
var at = s.IndexOf(engineCall, StringComparison.Ordinal);
Assert.True(at > 0, $"未找到引擎调用点:{engineCall}");
Assert.True(s.LastIndexOf(Guard + "(", at, StringComparison.Ordinal) > 0,
$"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
}
///
/// 批量入口的循环体各自持有守卫。批量端点不会走单项方法体,
/// 少一处就等于批量仍可绕过(§21 / §22)。
///
[Fact]
public void BatchActions_EachLoopBodyCallsGuard()
{
var s = FlowTaskService();
Assert.Equal(4, CountOf(s, Guard + "(taskId)") + CountOf(s, Guard + "(input.TaskId)"));
foreach (var batch in new[] { "BatchApprove", "BatchReject" })
{
var body = Slice(s, $"public async Task {batch}(", "return result;");
Assert.Contains(Guard + "(taskId)", body);
}
}
// ── §11 Guard 必须早于任何 mutation ──
/// 守卫是动作方法的第一条语句,因此 fail 时任务状态/实例/日志/下一节点都还没动。
[Theory]
[InlineData("public async Task Approve(TaskActionInput input)")]
[InlineData("public async Task Reject(TaskActionInput input)")]
public void Guard_IsFirstStatementOfAction(string signature)
{
var body = Slice(FlowTaskService(), signature, "}");
var lines = body.Split('\n').Select(l => l.Trim()).Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)).ToList();
var first = lines.FirstOrDefault(l => !l.StartsWith("public ", StringComparison.Ordinal));
Assert.Equal($"await {Guard}(input.TaskId);", first);
}
/// 守卫自身只读不写:不得在判定过程里改任何实体。
[Fact]
public void Guard_PerformsNoMutation()
{
var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// ");
foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
Assert.DoesNotContain(write, guard);
}
// ── §10 精确匹配 ──
/// 必须是精确字典查表,禁止 Contains / StartsWith 之类模糊匹配。
[Fact]
public void Guard_UsesExactDictionaryLookup()
{
var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// ");
Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", guard);
foreach (var fuzzy in new[] { ".Contains(", ".StartsWith(", ".EndsWith(", "ToUpper", "ToLower" })
Assert.DoesNotContain(fuzzy, guard);
}
/// §16 Control:明确允许通用审批的 BizType 不得被纳入。
[Theory]
[InlineData("CONTRACT_REVIEW")]
[InlineData("ORDER_REVIEW")]
[InlineData("SMART_OPS_IMPROVEMENT")]
[InlineData("EXCEPTION_REPORT")]
[InlineData("MATERIAL_SHORTAGE")]
public void Guard_DoesNotBlockGenericApprovalBizTypes(string bizType)
{
Assert.DoesNotContain(bizType, Dict());
}
///
/// §9 禁止散落 if:不得在动作方法里各写一遍 S7 的判断。
///
/// 字面量允许出现的位置恰好两处,且都是声明式集合成员,不是条件分支:
/// ① DedicatedEntryOnlyBizTypes 的键(名单与文案的唯一来源);
/// ② FlowMutationDedicatedOnlyBizTypes 的成员(封堵范围是否延伸到变更型入口)。
/// 真正要防的「散落 if」由下面的 DoesNotContain("== ...") 断言守住。
///
[Fact]
public void NoScatteredPerBizTypeIfStatements()
{
var s = FlowTaskService();
Assert.Equal(2, CountOf(s, $"\"{BizType}\""));
Assert.DoesNotContain($"== \"{BizType}\"", s);
Assert.DoesNotContain($"if (instance.BizType == \"{BizType}\"", s);
}
// ── §12 权威来源与不泄漏 ──
/// 权威 BizType 必须从库中 instance 反查,不得取自请求体(否则可伪造绕过)。
[Fact]
public void Guard_ReadsBizTypeFromDatabase()
{
var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// ");
Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
Assert.DoesNotContain("input.BizType", guard);
}
/// §13 对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。
[Fact]
public void GuardMessage_DoesNotLeakInternals()
{
var dict = Dict();
foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId", "InstanceId" })
Assert.DoesNotContain($"{leak}=", dict);
}
/// 文案必须指向专用业务页,且不是裸技术异常。
[Fact]
public void GuardMessage_PointsToDedicatedPage()
{
var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
Assert.Contains("FQC检验单详情", line);
Assert.Contains("不支持在审批中心直接同意/拒绝", line);
foreach (var bad in new[] { "BizType not allowed", "NullReference", "500" })
Assert.DoesNotContain(bad, line);
}
// ── 封堵的前提:专用入口必须仍然存在且直调引擎 ──
/// 四个专用入口都必须在(否则封堵等于封死业务)。
[Theory]
[InlineData("submit-result")]
[InlineData("supervisor-approve")]
[InlineData("supervisor-reject")]
[InlineData("qe-submit-disposition")]
public void DedicatedEntries_StillExist(string route)
{
Assert.Contains($"[HttpPost(\"{route}\")]", FlowService());
}
/// 专用入口直调 FlowEngine,不经 FlowTaskService —— 故守卫不会误伤正常业务路径。
[Fact]
public void DedicatedEntries_CallEngineDirectly_NotViaFlowTaskService()
{
var s = FlowService();
Assert.Contains("_flowEngine.Approve(", s);
Assert.Contains("_flowEngine.ReturnToPrev(", s);
Assert.DoesNotContain("FlowTaskService", s);
Assert.DoesNotContain("flowTask/approve", s);
}
///
/// 冻结"为什么必须封堵"的两条根据:
/// ① Handler 回调全 no-op(业务副作用不在引擎里);② 专用 reject 走 ReturnToPrev 而非置终态。
/// 任一条将来被改动,本守卫的理由需要重新评估,测试在此提醒。
///
[Fact]
public void BypassRationale_HandlerCallbacksAreNoOp()
{
var h = FlowConst();
foreach (var cb in new[] { "OnFlowStarted", "OnNodeCompleted", "OnFlowCompleted" })
{
var line = h.Split('\n').Single(l => l.Contains($"public Task {cb}(", StringComparison.Ordinal));
Assert.Contains("Task.CompletedTask", line);
}
}
/// 专用 supervisor-reject 的业务副作用必须仍在(它们正是通用 Reject 绕过的东西)。
[Fact]
public void BypassRationale_DedicatedRejectHasBusinessSideEffects()
{
var body = Slice(FlowService(), "public async Task SupervisorReject(", "public async Task QeSubmitDisposition(");
Assert.Contains("退回必须填写意见", body);
Assert.Contains("_flowEngine.ReturnToPrev(", body);
Assert.Contains("UPDATE qms_fqcbj SET FINSPECTSTATUS", body);
Assert.Contains("SyncBillToStdSafeAsync", body);
}
// ══════════════════════════════════════════════════════════════
// 变更型通用入口封堵(S7-FQC-GENERIC-FLOW-MUTATION-GUARD-1)
//
// 上一批 Evidence Audit 确认:DedicatedEntryOnly 守卫此前只挂在
// approve / reject / batchApprove / batchReject 四处,而 ReturnToPrev /
// Withdraw / Transfer / AddSign 四个**会改流程状态**的入口全部直通引擎、零守卫。
// 本区块冻结这四处的封堵,并冻结「不得顺手扩大到其它 BizType」。
// ══════════════════════════════════════════════════════════════
private const string MutationGuardByTask = "EnsureDedicatedFlowMutationOnlyByTaskAsync";
private const string MutationGuardByInstance = "EnsureDedicatedFlowMutationOnlyByInstanceAsync";
private static string MutationScope() =>
Slice(FlowTaskService(), "FlowMutationDedicatedOnlyBizTypes = new(StringComparer.Ordinal)", "};");
///
/// T1–T4:四个变更型入口各自的守卫必须是方法的**第一条语句**。
///
/// 为什么必须是第一条:引擎侧这四个动作都没有 [UnitOfWork],
/// 一旦进入就按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」
/// 逐步提交,中途抛错无法回滚。守卫晚一步就不再是 ZERO MUTATION。
///
[Theory]
[InlineData("public async Task ReturnToPrev(TaskActionInput input)", MutationGuardByTask, "input.TaskId")]
[InlineData("public async Task Transfer(TaskTransferInput input)", MutationGuardByTask, "input.TaskId")]
[InlineData("public async Task AddSign(TaskAddSignInput input)", MutationGuardByTask, "input.TaskId")]
[InlineData("public async Task Withdraw(TaskWithdrawInput input)", MutationGuardByInstance, "input.InstanceId")]
public void FlowMutationEntries_GuardIsFirstStatement(string signature, string guard, string arg)
{
var body = Slice(FlowTaskService(), signature, "}");
var first = body.Split('\n').Select(l => l.Trim())
.Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)
&& !l.StartsWith("public ", StringComparison.Ordinal))
.FirstOrDefault();
Assert.Equal($"await {guard}({arg});", first);
}
/// T5:守卫调用必须严格早于对应的 FlowEngine mutation 调用。
[Theory]
[InlineData("public async Task ReturnToPrev(TaskActionInput input)", "_engine.ReturnToPrev(")]
[InlineData("public async Task Transfer(TaskTransferInput input)", "_engine.Transfer(")]
[InlineData("public async Task AddSign(TaskAddSignInput input)", "_engine.AddSign(")]
[InlineData("public async Task Withdraw(TaskWithdrawInput input)", "_engine.Withdraw(")]
public void FlowMutationEntries_GuardPrecedesEngineCall(string signature, string engineCall)
{
var body = Slice(FlowTaskService(), signature, "}");
var guardAt = body.IndexOf("EnsureDedicatedFlowMutationOnly", StringComparison.Ordinal);
var engineAt = body.IndexOf(engineCall, StringComparison.Ordinal);
Assert.True(guardAt >= 0, $"{signature} 缺少变更型守卫");
Assert.True(engineAt > 0, $"未找到引擎调用 {engineCall}");
Assert.True(guardAt < engineAt, $"{signature} 的守卫晚于 {engineCall},无法保证 ZERO MUTATION");
}
///
/// T6:封堵范围不得顺手扩大——其余三个 dedicated BizType 目前仅封堵同意/拒绝,
/// 纳入变更型入口会改变它们的现有行为,属另批范围。
///
[Theory]
[InlineData("S5_IQC_INSPBILL")]
[InlineData("S6_PROCESS_INSPECTION")]
[InlineData("IPQC_INSPECTION")]
public void FlowMutationScope_ExcludesOtherDedicatedBizTypes(string bizType)
{
Assert.DoesNotContain(bizType, MutationScope());
}
/// T6 续:作用域恰好一条,且必须是主名单的子集(证明它不是第二套名单)。
[Fact]
public void FlowMutationScope_IsSingleMemberSubsetOfMainDict()
{
var scope = MutationScope();
Assert.Equal(2, CountOf(scope, "\"")); // 恰好一个字符串成员 → 一对引号
Assert.Contains($"\"{BizType}\"", scope);
Assert.Contains($"[\"{BizType}\"]", Dict());
}
/// T6 续:通用审批 BizType 不得被纳入变更型封堵。
[Theory]
[InlineData("CONTRACT_REVIEW")]
[InlineData("ORDER_REVIEW")]
[InlineData("SMART_OPS_IMPROVEMENT")]
public void FlowMutationScope_DoesNotBlockGenericBizTypes(string bizType)
{
Assert.DoesNotContain(bizType, MutationScope());
}
/// T7:原 approve/reject/batch 守卫必须仍然调用**原**守卫,未被新守卫替换。
[Theory]
[InlineData("public async Task Approve(TaskActionInput input)")]
[InlineData("public async Task Reject(TaskActionInput input)")]
public void ApproveReject_StillUseOriginalGuard(string signature)
{
var body = Slice(FlowTaskService(), signature, "}");
Assert.Contains($"{Guard}(input.TaskId)", body);
Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
}
///
/// T8:Escalate / Urge 本批明确 OUT OF SCOPE,不得被误加守卫。
/// Escalate 当前因 S7 定义未配 manual escalation 而在变更前即抛错;
/// Urge 不改流程状态,其权限问题属 ApprovalFlow 通用治理。
///
[Theory]
[InlineData("public async Task Escalate(TaskActionInput input)", "_engine.Escalate(")]
[InlineData("public async Task Urge(TaskUrgeInput input)", "_engine.Urge(")]
public void EscalateAndUrge_RemainUntouched(string signature, string engineCall)
{
var body = Slice(FlowTaskService(), signature, "}");
Assert.Contains(engineCall, body);
Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
Assert.DoesNotContain(Guard + "(", body);
}
/// 两个新守卫自身只读——判定过程不得改任何实体(ZERO MUTATION 的静态前提)。
[Theory]
[InlineData(MutationGuardByTask)]
[InlineData(MutationGuardByInstance)]
public void FlowMutationGuards_PerformNoMutation(string guard)
{
var body = Slice(FlowTaskService(), $"private async Task {guard}(long", "}\n\n /// ");
foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
Assert.DoesNotContain(write, body);
}
/// 新守卫的权威 BizType 同样必须来自库,且复用同一字典(不得自带文案体系)。
[Fact]
public void FlowMutationGuard_ReadsBizTypeFromDatabaseAndReusesDict()
{
var byInstance = Slice(FlowTaskService(), $"private async Task {MutationGuardByInstance}(long", "}\n\n /// ");
Assert.Contains("_instanceRep.GetByIdAsync(instanceId)", byInstance);
Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType", byInstance);
Assert.DoesNotContain("input.BizType", byInstance);
var byTask = Slice(FlowTaskService(), $"private async Task {MutationGuardByTask}(long", "}\n\n /// ");
Assert.Contains("_taskRep.GetByIdAsync(taskId)", byTask);
Assert.Contains($"{MutationGuardByInstance}(task.InstanceId)", byTask);
}
/// S7 文案必须覆盖本批新增的四个动作,否则用户点「转办」却看到「不支持同意/拒绝」。
[Fact]
public void GuardMessage_CoversFlowMutationActions()
{
var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
foreach (var act in new[] { "退回", "撤回", "转办", "加签" })
Assert.Contains(act, line);
}
///
/// 封堵成立的前提:S7 合法退回能力仍在专用入口上。
/// 通用 ReturnToPrev 被封后,N2→N1 必须仍可经 supervisor-reject 完成。
///
[Fact]
public void FlowMutationBlock_LeavesDedicatedRejectAsTheOnlyReturnPath()
{
var s = FlowService();
Assert.Contains("[HttpPost(\"supervisor-reject\")]", s);
Assert.Contains("_flowEngine.ReturnToPrev(", s);
// 专用入口直调引擎,不经 FlowTaskService,故不会被本批守卫拦住
Assert.DoesNotContain("FlowTaskService", s);
}
private static int CountOf(string src, string needle)
{
int n = 0, i = 0;
while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
return n;
}
private static string Slice(string src, string from, string to)
{
var a = src.IndexOf(from, StringComparison.Ordinal);
Assert.True(a >= 0, $"未找到起点:{from}");
var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
return b > a ? src[a..b] : src[a..];
}
private static string FindFile(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(string.Join("/", parts));
}
}