Jelajahi Sumber

fix(flow): 封堵 S6 过程检验单的通用审批旁路

审批中心的 /api/flowTask/approve|reject|batchApprove|batchReject 此前对
S6_PROCESS_INSPECTION 不设防,可直接推进流程,绕过 S6ProcessInspectionReview
专用入口的全部业务 authority。

其中 N3_QE_DISPOSITION 最严重:处置记录 ado_s6_ipqc_quality_disposition 是由
qe-disposition 在同一事务内先 INSERT 再推进流程的。走通用审批会把流程直接推到
end 而永不写处置记录 —— 流程显示已闭环、处置轨迹却为空,事后无法补记。
另:专用 supervisor-return 要求退回意见必填且回退到 N1,通用 Reject 会把实例
直接置为终态。

前端虽已对 S6 隐藏单条同意/拒绝按钮,但批量勾选列无 bizType 过滤,
batchApprove/batchReject 仍可绕过,故只能靠后端网关 fail-closed 才算闭环。

复用 Batch 5A 为 S5 建立的 DedicatedEntryOnlyBizTypes 守卫,不另起判定逻辑;
权威 bizType 从库中 task→instance 反查,不信任请求体。

S6 专用链本身已是原子的(四个动作各自 UseTranAsync,引擎写入经 SqlSugar 单例
连接并入同一事务),本批刻意不改,仅以契约测试冻结该性质防回归。

Runtime 验证(租户 838257186181189):
- 四个通用端点对 S6 N2 任务均 400 并给出业务页引导,实例/任务/日志零变更
- 守卫先于引擎自身校验(已处理的 S5 任务返回守卫文案,
  CONTRACT_REVIEW 返回引擎的「该任务已处理」→ 未误伤)
- 事务回滚实测:bill 4 提交审核时 N2 审批人为空抛错,StartFlow 已写入的
  实例+N1任务+日志全部回滚,Flow 四表计数与基线逐字节相同
YY968XX 5 hari lalu
induk
melakukan
1ef6326ae5

+ 178 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S6ProcessInspectionGenericApprovalGuardContractTests.cs

@@ -0,0 +1,178 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S6 过程检验单(S6_PROCESS_INSPECTION)通用审批旁路封堵 · 源码契约测试。
+///
+/// 背景(本批取证):
+///   守卫名单原先只含 S5,S6 落在名单外 ⇒ /api/flowTask/approve|reject|batchApprove|batchReject
+///   可直接推进 S6 流程。其中 N3_QE_DISPOSITION 最严重——处置记录
+///   ado_s6_ipqc_quality_disposition 是由 S6ProcessInspectionReview.qe-disposition 在
+///   **同一事务内** 先 INSERT 再推进流程的;走通用审批会把流程推到 end 而永不写处置记录,
+///   造成「流程已闭环但处置轨迹为空」且事后无法补记。
+///   另:专用 supervisor-return 要求意见必填且 ReturnToPrev 回 N1,通用 Reject 直接置实例终态。
+///
+///   前端虽已对 S6 隐藏单条同意/拒绝按钮,但批量勾选列无 bizType 过滤,
+///   batchApprove/batchReject 仍可绕过 ⇒ 只能靠后端网关 fail-closed 才算闭环。
+///
+/// 另一半同样重要:S6 专用链**本来就是原子的**(四个动作各自 UseTranAsync 且引擎写入
+/// 经 SqlSugar 单例连接并入同一事务),本批刻意**不改**它。下方的事务契约断言用于把这个
+/// 已验证的性质冻结住,防止日后被无意改坏。
+///
+/// 沿用本仓对 FlowEngine / FlowTaskService 的既有做法——源码契约断言而非行为级集成测试:
+/// 该层依赖 SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
+/// </summary>
+public class S6ProcessInspectionGenericApprovalGuardContractTests
+{
+    private const string BizType = "S6_PROCESS_INSPECTION";
+    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 ReviewService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
+
+    private static string FlowConst() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionFlowConst.cs"));
+
+    private static string PendingList() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "approvalFlow", "center", "components", "PendingList.vue"));
+
+    // ── 守卫 ──
+
+    /// <summary>S6 必须在「只能走专用入口」名单里,且文案指向业务页而非泛化的 forbidden。</summary>
+    [Fact]
+    public void Guard_ListsS6AsDedicatedEntryOnly()
+    {
+        var s = FlowTaskService();
+        Assert.Contains($"[\"{BizType}\"]", s);
+        Assert.Contains("过程检验单请在", s);
+    }
+
+    /// <summary>S6 与 S5 共用同一个守卫,不得为 S6 另起一套判定逻辑。</summary>
+    [Fact]
+    public void Guard_ReusesSharedMechanism_NotAParallelImplementation()
+    {
+        var s = FlowTaskService();
+        Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
+        // 名单是精确字典查找,不得退化成模糊匹配把无关业务一起挡掉
+        Assert.DoesNotContain("StartsWith(\"S6", s);
+        Assert.DoesNotContain("Contains(\"PROCESS\")", s);
+    }
+
+    /// <summary>四个会推进流程的通用端点都必须在调用引擎之前先过守卫(批量端点绕过单条方法体)。</summary>
+    [Theory]
+    [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Approve(taskId, input.Comment)")]
+    [InlineData("_engine.Reject(taskId, input.Comment)")]
+    public void Guard_RunsBeforeEngineCall(string engineCall)
+    {
+        var s = FlowTaskService();
+        var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal);
+        Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}");
+        Assert.True(s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal) > 0,
+            $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
+    }
+
+    /// <summary>权威 BizType 必须来自库中的 instance,不得取自请求体(否则可伪造绕过)。</summary>
+    [Fact]
+    public void Guard_ReadsBizTypeFromDatabase()
+    {
+        var s = FlowTaskService();
+        var guard = Slice(s, $"private async Task {Guard}(long taskId)", "}\n\n    /// <summary>");
+        Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
+        Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
+        Assert.DoesNotContain("input.BizType", guard);
+    }
+
+    // ── 节点码(连任务书都写错过,必须钉死)──
+
+    /// <summary>S6 的处置节点是 N3_QE_DISPOSITION;N3_QUALITY_DISPOSITION 属另一条 IPQC_INSPECTION 链。</summary>
+    [Fact]
+    public void S6_UsesQeDispositionNode_NotQualityDisposition()
+    {
+        var s = FlowConst();
+        Assert.Contains("NodeSubmit = \"N1_SUBMIT\"", s);
+        Assert.Contains("NodeSupervisor = \"N2_SUP_REVIEW\"", s);
+        Assert.Contains("NodeDisposition = \"N3_QE_DISPOSITION\"", s);
+        Assert.DoesNotContain("N3_QUALITY_DISPOSITION", s);
+        Assert.Contains($"BizType = \"{BizType}\"", s);
+    }
+
+    // ── 专用链的既有性质:本批不改,冻结防回归 ──
+
+    /// <summary>四个流程动作必须各自包在 UseTranAsync 内,并对失败 rethrow。</summary>
+    [Theory]
+    [InlineData("SubmitReview")]
+    [InlineData("SupervisorApprove")]
+    [InlineData("SupervisorReturn")]
+    [InlineData("QeDisposition")]
+    public void DedicatedActions_StayTransactional(string method)
+    {
+        var s = ReviewService();
+        var at = s.IndexOf($"public async Task<S6ReviewStateOutput> {method}(", StringComparison.Ordinal);
+        Assert.True(at > 0, $"未找到方法 {method}");
+        var body = s[at..];
+        var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
+        var checkAt = body.IndexOf("if (!tran.IsSuccess) throw tran.ErrorException;", StringComparison.Ordinal);
+        Assert.True(tranAt > 0 && tranAt < 4000, $"{method} 缺少 UseTranAsync 事务边界");
+        Assert.True(checkAt > tranAt, $"{method} 缺少 tran.IsSuccess 失败 rethrow");
+    }
+
+    /// <summary>处置记录 INSERT 必须与流程推进同处一个事务,这是禁止通用审批的根本理由。</summary>
+    [Fact]
+    public void QeDisposition_InsertsDispositionInsideSameTransactionAsApprove()
+    {
+        var s = ReviewService();
+        var body = Slice(s, "public async Task<S6ReviewStateOutput> QeDisposition(", "if (!tran.IsSuccess)");
+        var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
+        var insertAt = body.IndexOf("_db.Insertable(new AdoS6IpqcQualityDisposition", StringComparison.Ordinal);
+        var approveAt = body.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal);
+        Assert.True(tranAt > 0, "QeDisposition 缺少事务");
+        Assert.True(insertAt > tranAt, "处置记录 INSERT 必须在事务内");
+        Assert.True(approveAt > insertAt, "必须先写处置记录再推进流程");
+    }
+
+    /// <summary>处置动作不得改写质量结论(overall_result 只读,既有契约)。</summary>
+    [Fact]
+    public void QeDisposition_NeverOverwritesOverallResult()
+    {
+        Assert.DoesNotContain("SET overall_result", ReviewService());
+    }
+
+    // ── 前端:既有 fail-closed 不得回退 ──
+
+    /// <summary>审批中心对 S6 仍只给跳转按钮,不得回退为通用同意/拒绝。</summary>
+    [Fact]
+    public void PendingList_KeepsS6DeepLinkOnly()
+    {
+        var s = PendingList();
+        var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
+        Assert.Contains("goToS6(row.bizId)", branch);
+        Assert.DoesNotContain("emit('approve'", branch);
+        Assert.DoesNotContain("emit('reject'", branch);
+    }
+
+    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));
+    }
+}

+ 5 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

@@ -174,6 +174,11 @@ public class FlowTaskService : IDynamicApiController, ITransient
     private static readonly Dictionary<string, string> DedicatedEntryOnlyBizTypes = new()
     private static readonly Dictionary<string, string> DedicatedEntryOnlyBizTypes = new()
     {
     {
         ["S5_IQC_INSPBILL"] = "来料检验单请在「来料检验单详情」页完成审核,不支持在审批中心直接同意/拒绝。",
         ["S5_IQC_INSPBILL"] = "来料检验单请在「来料检验单详情」页完成审核,不支持在审批中心直接同意/拒绝。",
+        // S6 过程检验单:N3_QE_DISPOSITION 的处置记录(ado_s6_ipqc_quality_disposition)是由
+        // S6ProcessInspectionReview.qe-disposition 在同一事务里 INSERT 后才推进流程的。走通用审批会把
+        // 流程直接推到 end 而永不写处置记录 —— 流程显示已闭环、处置轨迹却为空,且事后无法补记。
+        // 另:专用 supervisor-return 要求退回意见必填且回退到 N1,通用 Reject 会把实例直接置为终态。
+        ["S6_PROCESS_INSPECTION"] = "过程检验单请在「过程检验」业务页完成主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
     };
     };
 
 
     /// <summary>
     /// <summary>