| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S7;
- /// <summary>
- /// S7-FQC-INITIATOR-TENANT-GUARD-1 契约测试:阻止 S7 FQC 再产生跨租户 jyfzr / Initiator。
- ///
- /// 要守住的不变量:
- /// <c>BusinessTenant == jyfzr 用户.TenantId == Instance.Initiator 用户.TenantId</c>。
- /// S7 契约是三者恒等(SubmitResult 的负责人门 + GenerateInspBill 的认领人门保证
- /// jyfzr == 当前用户 == N1 Assignee),故只要 jyfzr 恒同租户,Initiator 就恒同租户。
- ///
- /// 三道防线:Assign → Claim → 起流边界(GenerateInspBill / SubmitResult)。
- ///
- /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:这些服务依赖
- /// ISqlSugarClient / UserManager / FlowEngineService,裸 xUnit 进程无法实例化。
- /// 真实 ALLOW/DENY 由本批 Runtime R1–R7 覆盖。
- /// </summary>
- public class S7FqcInitiatorTenantGuardTests
- {
- private static string Guard() => File.ReadAllText(FindFile(
- "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "S7FqcInitiatorTenantGuard.cs"));
- private static string TaskEntry() => File.ReadAllText(FindFile(
- "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcTaskEntryService.cs"));
- private static string FlowService() => File.ReadAllText(FindFile(
- "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs"));
- private static string Engine() => File.ReadAllText(FindFile(
- "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
- private static string FlowTaskService() => File.ReadAllText(FindFile(
- "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
- // ───────────── 守卫本体 ─────────────
- /// <summary>§十七:不得只判「用户存在」,必须显式比较 SysUser.TenantId 与单据租户。</summary>
- [Fact]
- public void Guard_ComparesUserTenantAgainstBusinessTenant()
- {
- var g = Guard();
- Assert.Contains("if (user.TenantId != businessTenantId)", g);
- Assert.Contains("不属于当前单据租户", g);
- }
- /// <summary>存在性与启用状态也要判 —— 三者缺一都不能当负责人。</summary>
- [Theory]
- [InlineData("if (user == null)")]
- [InlineData("if (user.Status != StatusEnum.Enable)")]
- [InlineData("if (userId <= 0)")]
- public void Guard_ChecksExistenceAndEnabled(string expected)
- {
- Assert.Contains(expected, Guard());
- }
- /// <summary>
- /// 必须跨租户查用户(ClearFilter<ITenantIdFilter>)—— 否则跨租户用户表现为「查不到」,
- /// 无法把「不存在」与「存在但不属于本租户」区分开,只能给出误导性文案。
- /// </summary>
- [Fact]
- public void Guard_LooksUpUserAcrossTenantsOnPurpose()
- {
- var g = Guard();
- Assert.Contains("ClearFilter<ITenantIdFilter>()", g);
- Assert.Contains("db.Queryable<SysUser>()", g);
- }
- /// <summary>§二十七:文案不得泄漏 TenantId / RoleId / 内部过滤器。</summary>
- [Theory]
- [InlineData("TenantId=")]
- [InlineData("RoleId")]
- [InlineData("ITenantIdFilter}")]
- public void Guard_MessageDoesNotLeakInternals(string leak)
- {
- var messages = string.Join("\n", Guard().Split('\n').Where(l => l.Contains("Oops.Oh(", StringComparison.Ordinal)));
- Assert.DoesNotContain(leak, messages);
- }
- /// <summary>存量脏数据:jyfzr 可能根本不是用户 Id,必须显式拒绝而不是当 0 处理。</summary>
- [Fact]
- public void Guard_RejectsNonNumericLegacyOwnerValue()
- {
- var body = Slice(Guard(), "public static async Task EnsureBillOwnerTenantSafeAsync", "private sealed class GuardUser");
- Assert.Contains("!long.TryParse(raw, out var ownerId) || ownerId <= 0", body);
- Assert.Contains("检验负责人记录无效", body);
- Assert.Contains("尚未指定检验负责人", body);
- }
- /// <summary>守卫只读不写 —— 任何 blocked 路径都不能产生副作用。</summary>
- [Theory]
- [InlineData("AsInsertable")]
- [InlineData("AsUpdateable")]
- [InlineData("ExecuteCommandAsync")]
- [InlineData("Deleteable")]
- public void Guard_PerformsNoMutation(string write)
- {
- Assert.DoesNotContain(write, CsCode(Guard()));
- }
- // ───────────── §二十九 Assign ─────────────
- /// <summary>Assign 必须在写 jyfzr **之前**校验目标用户。</summary>
- [Fact]
- public void Assign_GuardsBeforeWritingOwner()
- {
- var body = Slice(TaskEntry(), "public async Task<object> Assign(", "/// <summary>优先级调整");
- var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal);
- var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal);
- Assert.True(guardAt > 0, "Assign 缺少租户守卫");
- Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入");
- Assert.Contains("EnsureCanOwnFqcAsync(_db, input.InspectorUserId, tenantId, \"指定检验负责人\")", body);
- }
- // ───────────── §三十 Claim ─────────────
- /// <summary>Claim 必须校验**当前用户实体**的租户,而不是只看上下文租户等于单据租户。</summary>
- [Fact]
- public void Claim_GuardsCurrentUserEntityTenant()
- {
- var body = Slice(TaskEntry(), "public async Task<object> Claim(", "/// <summary>检验员调配");
- var guardAt = body.IndexOf("EnsureCanOwnFqcAsync", StringComparison.Ordinal);
- var writeAt = body.IndexOf("UPDATE qms_fqcbj SET jyfzr", StringComparison.Ordinal);
- Assert.True(guardAt > 0, "Claim 缺少租户守卫");
- Assert.True(writeAt > guardAt, "守卫必须早于 jyfzr 写入");
- Assert.Contains("EnsureCanOwnFqcAsync(_db, _userManager.UserId, tenantId, \"当前用户\")", body);
- }
- /// <summary>
- /// §十九/§二十:超管切租户后 token 的 TenantId claim 是目标租户、SysUser.TenantId 仍是默认租户。
- /// 守卫比对的必须是 SysUser.TenantId(跨租户查出来的实体),不是 _userManager.TenantId。
- /// </summary>
- [Fact]
- public void Claim_DoesNotTrustTokenTenantAsUserTenant()
- {
- var g = Guard();
- // 守卫**实现**内不得出现「用 _userManager.TenantId 当作用户归属」的写法
- Assert.DoesNotContain("_userManager", CsCode(g));
- Assert.Contains("user.TenantId != businessTenantId", g);
- }
- // ───────────── §三十一 起流边界 ─────────────
- /// <summary>GenerateInspBill 在 EnsureFlowAsync(起流)之前校验业务行上的 jyfzr。</summary>
- [Fact]
- public void GenerateInspBill_GuardsBeforeStartFlow()
- {
- var body = Slice(TaskEntry(), "public async Task<FqcTaskGenerateOutput> GenerateInspBill(", "private async Task EnsureFlowAsync");
- var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal);
- var startAt = body.IndexOf("EnsureFlowAsync(bill.Id", StringComparison.Ordinal);
- Assert.True(guardAt > 0, "GenerateInspBill 缺少起流边界守卫");
- Assert.True(startAt > guardAt, "守卫必须早于起流");
- }
- /// <summary>SubmitResult 在事务(含 StartFlow)之前校验 jyfzr。</summary>
- [Fact]
- public void SubmitResult_GuardsBeforeTransactionAndStartFlow()
- {
- var body = Slice(FlowService(), "public async Task<FqcFlowStateOutput> SubmitResult(", "/// <summary>主管审核通过");
- var guardAt = body.IndexOf("EnsureBillOwnerTenantSafeAsync", StringComparison.Ordinal);
- var tranAt = body.IndexOf("UseTranAsync", StringComparison.Ordinal);
- var startAt = body.IndexOf("_flowEngine.StartFlow", StringComparison.Ordinal);
- Assert.True(guardAt > 0, "SubmitResult 缺少起流边界守卫");
- Assert.True(tranAt > guardAt, "守卫必须早于事务开启");
- Assert.True(startAt > guardAt, "守卫必须早于 StartFlow");
- }
- /// <summary>业务租户必须来自业务上下文/业务行,不是 token 直接透传(§十)。</summary>
- [Fact]
- public void StartFlowBoundary_UsesBusinessTenant()
- {
- Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, apply.OwnerId, tenantId)", TaskEntry());
- Assert.Contains("EnsureBillOwnerTenantSafeAsync(_db, jyfzrOwner, gateTenantId)", FlowService());
- }
- /// <summary>三道防线都在,形成 defense-in-depth(§十五)。</summary>
- [Fact]
- public void ThreeLayersOfDefenseArePresent()
- {
- var t = TaskEntry();
- Assert.Equal(2, CountOf(t, "EnsureCanOwnFqcAsync")); // Assign + Claim
- Assert.Equal(1, CountOf(t, "EnsureBillOwnerTenantSafeAsync")); // GenerateInspBill
- Assert.Equal(1, CountOf(FlowService(), "EnsureBillOwnerTenantSafeAsync")); // SubmitResult
- }
- // ───────────── §二十四/§五十三 范围守卫 ─────────────
- /// <summary>不得为 S7 改 FlowEngine:引擎照实记录操作者是正确行为,改它会扩散到全部 BizType。</summary>
- [Theory]
- [InlineData("S7_FQC_INSPBILL")]
- [InlineData("FqcInspBillFlowConst")]
- public void FlowEngine_HasNoS7SpecialCase(string forbidden)
- {
- Assert.DoesNotContain(forbidden, CsCode(Engine()));
- }
- /// <summary>引擎仍按「当前用户」捕获 Initiator —— 本批没有改这条语义。</summary>
- [Fact]
- public void FlowEngine_StillCapturesCurrentUserAsInitiator()
- {
- Assert.Contains("InitiatorId = _userManager.UserId,", Engine());
- }
- /// <summary>守卫不得改写业务语义:jyfzr 仍是 SysUser.Id,Initiator 仍 == jyfzr == 当前用户。</summary>
- [Fact]
- public void Contract_InitiatorEqualsOwnerEqualsCurrentUser_Preserved()
- {
- var f = FlowService();
- Assert.Contains("当前用户不是该检验单负责人,无权提交检验结果", f);
- Assert.Contains("jyfzr==当前用户==N1 Assignee 三者一致", f);
- Assert.Contains("尚未认领或非认领人操作!", TaskEntry());
- }
- // ───────────── §四十四/§四十五 回归 ─────────────
- /// <summary>S7 通用审批旁路守卫必须仍然生效。</summary>
- [Fact]
- public void GenericApprovalGuard_StillCoversS7()
- {
- var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
- Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
- }
- /// <summary>本批不得把 S7 加入 TenantStrictRoleBizTypes(属另一治理边界)。</summary>
- [Fact]
- public void StrictRoleGuard_StillNotOpenedForS7()
- {
- var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
- Assert.DoesNotContain("S7_FQC_INSPBILL", set);
- }
- /// <summary>§四十七/§五十五:本批是 prevention,不得出现任何数据迁移/历史回写。</summary>
- [Theory]
- [InlineData("ApprovalFlowInstance")]
- [InlineData("ApprovalFlowTask")]
- [InlineData("ApprovalFlowLog")]
- [InlineData("840240525086789")]
- public void Guard_NeverTouchesFlowOrHistoricalData(string forbidden)
- {
- Assert.DoesNotContain(forbidden, CsCode(Guard()));
- }
- /// <summary>
- /// **去注释**后的 C# 源码视图。这些文件的注释会成段引用被淘汰/被解释的写法
- /// (如 FlowEngineService 注释里提到 S7_FQC_INSPBILL、守卫文档里提到 _userManager 与
- /// ApprovalFlowInstance)来说明「为何如此设计」;裸文本 DoesNotContain 会把说明当成违规实现。
- /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。
- /// </summary>
- private static string CsCode(string src)
- {
- var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
- var kept = s.Split('\n').Where(l =>
- {
- var t = l.TrimStart();
- return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("///", StringComparison.Ordinal);
- });
- return string.Join("\n", kept);
- }
- 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));
- }
- }
|