Prechádzať zdrojové kódy

fix(flow): block generic approval bypass for IPQC inspection | server 1.0.539

686824d5e 只把 S6_PROCESS_INSPECTION(Phase1C 链)加入守卫名单,同名不同源的
IPQC_INSPECTION(源 qms_gcjyd)一直在名单外。2026-09-14 实测:通用
flowTask/approve 真实把一条 IPQC N2 待办直接推进到 end。

破坏面比 Phase1C 链更大 —— 专用入口 IpqcInspectionDisposition.submit 在同一事务内:
① INSERT 处置记录 ado_s6_ipqc_quality_disposition;
② 把扩展表 ado_s6_ipqc_inspection_flow_state.business_status 推到 DISPOSED;
③ 才推进 N3 → end。
走通用审批 ①② 都不会发生:流程显示已闭环,处置轨迹为空且业务状态永久停在旧值,
事后无法补记。另:专用 supervisor-return 要求意见必填并经 ReturnToPrev 回 N1,
通用 Reject 直接置实例终态。

改动 = DedicatedEntryOnlyBizTypes 增一条,复用 Batch 5A 既有守卫机制与错误语义;
未新建第二套判定、未散落 if、精确字典查找不做前缀/包含匹配。
四个通用端点(approve/reject/batchApprove/batchReject)共用同一守卫,
且守卫是各方法的第一条语句,先于任何 _engine.* 调用。

Runtime 验证(租户 838257186181189,真实 Pending 任务而非已处理任务):
- fixture 按 modules/S6 DECISIONS D-001 建立(只写归口源表 qms_gcjyd/qms_gcjydzb、
  结果五列留 NULL、双重防重),经官方 inbound 同步,再由正式 submit-inspection
  产生真实 Pending N2 —— 未直写任何 Flow 表
- R1 approve / R2 reject 均 400 并给出业务页引导;R3/R4 批量 successCount=0 failCount=1
- Zero mutation:Flow 四表计数与基线逐字节相同(277/52428/52939/1097),
  实例仍 Status=1 @N2_SUP_REVIEW、UpdateTime/EndTime NULL,N2 任务仍 Pending、
  ActionTime/Comment NULL,该实例日志仍 2 条;业务三处(qms_gcjyd 五列 /
  扩展表 business_status / 处置表 0 行)全部未变
- R5-R7 专用入口回归:submit-inspection → N2 → supervisor-return(意见必填)→ N1
  → 重新提交 → supervisor-approve → end,全程未被守卫误伤
- R8 S6_PROCESS_INSPECTION 守卫未回归;R9 CONTRACT_REVIEW 落到引擎自身校验,未误伤
- R10 跨租户:797 账号访问 838 单据 → 400「检验单不存在」,0 mutation

验证:build 0 Error、改动文件 0 warning;全量 2420 passed / 1 failed
(S8AuthorizationGuardTests.MutationActions_DoNotUseReadOnlyCapabilities,
offender AdoS8OrderReviewKpiController 本批未改动,PRE-EXISTING)。
YY968XX 5 dní pred
rodič
commit
9b85efd953

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.538</AssemblyVersion>
-    <FileVersion>1.0.538</FileVersion>
-    <Version>1.0.538</Version>
+    <AssemblyVersion>1.0.539</AssemblyVersion>
+    <FileVersion>1.0.539</FileVersion>
+    <Version>1.0.539</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 217 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/IpqcInspectionGenericApprovalGuardContractTests.cs

@@ -0,0 +1,217 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S6 IPQC 过程检验单(<c>IPQC_INSPECTION</c>,源 qms_gcjyd)通用审批旁路封堵 · 源码契约测试。
+///
+/// 背景:`686824d5e` 只把 <c>S6_PROCESS_INSPECTION</c>(Phase1C 链)加入了守卫名单,
+/// 同名不同源的 <c>IPQC_INSPECTION</c> 一直在名单外。2026-09-14 实测:通用
+/// <c>flowTask/approve</c> 真实把一条 IPQC N2 待办直接推进到 end。
+///
+/// 破坏面比 Phase1C 链更大——专用入口 <c>IpqcInspectionDisposition.submit</c> 在**同一事务内**:
+///   ① INSERT 处置记录 <c>ado_s6_ipqc_quality_disposition</c>;
+///   ② 把扩展表 <c>ado_s6_ipqc_inspection_flow_state.business_status</c> 推到 DISPOSED;
+///   ③ 才 <c>_flowEngine.Approve</c> 推进 N3 → end。
+/// 走通用审批 ①② 都不会发生:流程显示已闭环,处置轨迹为空且业务状态永久停在旧值,事后无法补记。
+/// 另:专用 <c>supervisor-return</c> 要求意见必填并经 ReturnToPrev 回 N1,通用 Reject 直接置实例终态。
+///
+/// 本文件与 <see cref="S6ProcessInspectionGenericApprovalGuardContractTests"/> 分开:
+/// 后者还断言 Phase1C 链自己的 ReviewService / FlowConst / PendingList 形状,
+/// 改名或塞进去只会制造无必要 diff。两者共用同一套守卫机制,下方断言即冻结这一点。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖
+/// SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
+/// 真实阻断由本批 Runtime 矩阵 R1–R4 覆盖。
+/// </summary>
+public class IpqcInspectionGenericApprovalGuardContractTests
+{
+    private const string BizType = "IPQC_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 FlowConst() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs"));
+
+    private static string FlowService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowService.cs"));
+
+    private static string DispositionService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionDispositionService.cs"));
+
+    // ── 守卫覆盖 ──
+
+    /// <summary>IPQC_INSPECTION 必须在「只能走专用入口」名单里,且文案指向业务页。</summary>
+    [Fact]
+    public void Guard_ListsIpqcInspectionAsDedicatedEntryOnly()
+    {
+        var s = FlowTaskService();
+        Assert.Contains($"[\"{BizType}\"]", s);
+        Assert.Contains("过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置", s);
+    }
+
+    /// <summary>BizType 字面量必须与运行态一致(实测 len=15,无大小写/下划线变体)。</summary>
+    [Fact]
+    public void BizType_MatchesRuntimeLiteral()
+    {
+        Assert.Contains($"BizType = \"{BizType}\"", FlowConst());
+        Assert.Equal(15, BizType.Length);
+    }
+
+    /// <summary>扩展名单时不得把既有条目挤掉——S5 与 Phase1C 链必须仍在。</summary>
+    [Theory]
+    [InlineData("S5_IQC_INSPBILL")]
+    [InlineData("S6_PROCESS_INSPECTION")]
+    public void Guard_KeepsPreviouslyGuardedBizTypes(string bizType)
+    {
+        Assert.Contains($"[\"{bizType}\"]", FlowTaskService());
+    }
+
+    /// <summary>必须复用同一套守卫,不得为 IPQC 另起一份判定或散落 if。</summary>
+    [Fact]
+    public void Guard_ReusesSharedMechanism_NotAParallelImplementation()
+    {
+        var s = FlowTaskService();
+        Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
+        Assert.DoesNotContain($"bizType == \"{BizType}\"", s);
+        Assert.DoesNotContain($"BizType == \"{BizType}\"", s);
+    }
+
+    /// <summary>必须精确匹配,不得退化成前缀/包含匹配把无关业务一起挡掉。</summary>
+    [Fact]
+    public void Guard_UsesExactMatch_NotFuzzy()
+    {
+        var s = FlowTaskService();
+        Assert.DoesNotContain("StartsWith(\"IPQC", s);
+        Assert.DoesNotContain("Contains(\"IPQC", s);
+        Assert.DoesNotContain("Contains(\"INSPECTION\")", s);
+        Assert.DoesNotContain("StartsWith(\"S6", 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 guard = Slice(FlowTaskService(), $"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>不误伤:名单是精确字典查找,未把通用审批业务(如 CONTRACT_REVIEW)纳入。</summary>
+    [Theory]
+    [InlineData("CONTRACT_REVIEW")]
+    [InlineData("S7_FQC_INSPBILL")]
+    [InlineData("SMART_OPS_IMPROVEMENT")]
+    [InlineData("EXCEPTION_REPORT")]
+    public void Guard_DoesNotBlockNonDedicatedBizTypes(string bizType)
+    {
+        var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
+        Assert.DoesNotContain(bizType, dict);
+    }
+
+    /// <summary>对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。</summary>
+    [Fact]
+    public void Guard_MessageDoesNotLeakInternals()
+    {
+        var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
+        foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId" })
+            Assert.DoesNotContain($"{leak}=", dict);
+    }
+
+    // ── 专用入口必须仍然可用(否则封堵等于封死业务)──
+
+    /// <summary>阻断通用入口的前提是四个节点都有合法专用入口,且都直调引擎、不经 FlowTaskService。</summary>
+    [Theory]
+    [InlineData("submit-inspection")]
+    [InlineData("supervisor-approve")]
+    [InlineData("supervisor-return")]
+    public void DedicatedFlowEntries_Exist(string route)
+    {
+        Assert.Contains($"HttpPost(\"{route}\")", FlowService());
+    }
+
+    /// <summary>N3 处置的专用入口在另一个 service。</summary>
+    [Fact]
+    public void DedicatedDispositionEntry_Exists()
+    {
+        var s = DispositionService();
+        Assert.Contains("Route(\"api/IpqcInspectionDisposition\")", s);
+        Assert.Contains("HttpPost(\"submit\")", s);
+    }
+
+    /// <summary>专用入口不得改走通用 FlowTaskService,否则会被自己的守卫挡住。</summary>
+    [Fact]
+    public void DedicatedEntries_CallEngineDirectly_NotFlowTaskService()
+    {
+        foreach (var src in new[] { FlowService(), DispositionService() })
+        {
+            Assert.Contains("_flowEngine.", src);
+            Assert.DoesNotContain("FlowTaskService", src);
+        }
+    }
+
+    // ── 冻结「为什么必须封堵」的那条事务性质 ──
+
+    /// <summary>处置记录与扩展表状态必须与推进同处一个事务,且先写后推——这是禁止通用审批的根本理由。</summary>
+    [Fact]
+    public void Disposition_WritesRecordAndStateInsideSameTransactionBeforeApprove()
+    {
+        var s = DispositionService();
+        var tranAt = s.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
+        var insertAt = s.IndexOf("_db.Insertable(disposition)", StringComparison.Ordinal);
+        var stateAt = s.IndexOf("UPDATE ado_s6_ipqc_inspection_flow_state", StringComparison.Ordinal);
+        var approveAt = s.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal);
+        Assert.True(tranAt > 0, "缺少事务边界");
+        Assert.True(insertAt > tranAt, "处置记录 INSERT 必须在事务内");
+        Assert.True(stateAt > tranAt, "扩展表状态更新必须在事务内");
+        Assert.True(approveAt > insertAt && approveAt > stateAt, "必须先落业务再推进流程");
+        Assert.Contains("if (!tran.IsSuccess) throw tran.ErrorException;", s);
+    }
+
+    /// <summary>专用退回要求意见必填并回退到 N1,与通用 Reject 的终态语义不同。</summary>
+    [Fact]
+    public void SupervisorReturn_RequiresCommentAndGoesBackToN1()
+    {
+        var s = FlowService();
+        Assert.Contains("throw Oops.Oh(\"退回必须填写意见\")", s);
+        Assert.Contains("_flowEngine.ReturnToPrev(", s);
+    }
+
+    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));
+    }
+}

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

@@ -179,6 +179,14 @@ public class FlowTaskService : IDynamicApiController, ITransient
         // 流程直接推到 end 而永不写处置记录 —— 流程显示已闭环、处置轨迹却为空,且事后无法补记。
         // 流程直接推到 end 而永不写处置记录 —— 流程显示已闭环、处置轨迹却为空,且事后无法补记。
         // 另:专用 supervisor-return 要求退回意见必填且回退到 N1,通用 Reject 会把实例直接置为终态。
         // 另:专用 supervisor-return 要求退回意见必填且回退到 N1,通用 Reject 会把实例直接置为终态。
         ["S6_PROCESS_INSPECTION"] = "过程检验单请在「过程检验」业务页完成主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
         ["S6_PROCESS_INSPECTION"] = "过程检验单请在「过程检验」业务页完成主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+        // S6 IPQC 过程检验单(源 qms_gcjyd,与上面 Phase1C 链同名不同源):同款旁路,且破坏面更大。
+        // ① N3_QUALITY_DISPOSITION 的处置记录(ado_s6_ipqc_quality_disposition)由
+        //    IpqcInspectionDisposition.submit 在同一事务内 INSERT 后才推进流程;
+        // ② 同一事务还会把扩展表 ado_s6_ipqc_inspection_flow_state 的 business_status 推到 DISPOSED。
+        // 走通用审批两者都不会发生 —— 流程显示已闭环,处置轨迹为空且业务状态永久停在旧值。
+        // 另:专用 supervisor-return 要求退回意见必填、经 ReturnToPrev 回到 N1,通用 Reject 直接置实例终态。
+        // 2026-09-14 实测:通用 approve 曾真实把一条 IPQC N2 待办推进到 end。
+        ["IPQC_INSPECTION"] = "过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
     };
     };
 
 
     /// <summary>
     /// <summary>