using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
///
/// S6-TENANT-AUTHORITY-1 源码契约测试:S6 两条 IPQC 审批链的审批权威必须租户本地化。
///
/// 背景(Evidence Audit 结论):
/// Primary = SYSTEMIC TENANT CLONE DEFECT —— 三个 S6 角色全库单例挂默认租户,
/// 流程定义把默认租户的**物理 RoleId** 写死进 FlowJson,各租户靠跨租户
/// SysUserRole 绑定借用;该绑定被删一行,整个租户的 N2 当场解析为 0 人。
/// Secondary = UAT ROLE DRIFT(引爆点,非根因)
/// Contributing = Resolver Branch B FAIL-SILENT
///
/// 目标模型:tenant-local Role + same-tenant membership + RoleCode authority
/// + tenant-scoped Definition + S6 专属 fail-closed 守卫。
///
/// 本层沿用本仓对 FlowEngine / 种子的既有做法——源码契约断言而非行为级集成测试:
/// 这些类型依赖 ISqlSugarClient / SqlSugarRepository<T>,裸 xUnit 进程无法实例化
/// (见 S8ExceptionFlowTenantContextContractTests 与 S5IqcGenericApprovalGuardContractTests 的同款说明)。
/// 真实解析行为由本批的 Runtime 矩阵 R1–R13 覆盖。
///
public class S6TenantAuthorityMigrationContractTests
{
private const string InspectorCode = "ROLE_S6_IPQC_INSPECTOR";
private const string SupervisorCode = "ROLE_S6_IPQC_SUPERVISOR";
private const string QeCode = "ROLE_S6_IPQC_QUALITY_ENGINEER";
private static string AuthoritySeed() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6TenantAuthoritySeed.cs"));
private static string IpqcSeed() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "IpqcInspectionFlowSeed.cs"));
private static string ProcessSeed() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6ProcessInspectionFlowSeed.cs"));
// ───────────── 角色种子:tenant-local ─────────────
/// 角色查重键必须是 (TenantId, Code);原实现的全局 Any(Code) 正是「全库单例」成因。
[Fact]
public void RoleSeed_DedupesByTenantAndCode_NotGlobally()
{
var s = AuthoritySeed();
Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", s);
// 不得退回「只要任何租户有这个 Code 就不再建」
Assert.DoesNotContain("Any(x => x.Code == code)", s);
}
/// 同 Code 在不同租户各一份是合法的:RoleId 必须逐租户新取号,不得全租户共用固定 Id。
[Fact]
public void RoleSeed_AllocatesPerTenantRoleId_ExceptDefaultTenantLegacy()
{
var s = AuthoritySeed();
Assert.Contains("YitIdHelper.NextId()", s);
// 默认租户沿用既有固定 Id(历史 FlowJson 与业务门禁仍按该 Id 判定,换号会打断它们)
Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", s);
Assert.Contains("spec.LegacyDefaultTenantRoleId", s);
}
/// 租户清单必须取自正式注册表 SysTenant,不得自行构造。
[Fact]
public void RoleSeed_EnumeratesTenantsFromOfficialRegistry()
{
Assert.Contains("Queryable()", AuthoritySeed());
}
/// 通用种子里禁止出现任何具体租户 Id 或 UAT 账号。
[Theory]
[InlineData("838257186181189")]
[InlineData("797403760988229")]
[InlineData("UATAdminA")]
[InlineData("UATQualityA")]
[InlineData("UATQualityB")]
public void Seeds_NeverHardcodeTenantOrUatAccount(string forbidden)
{
Assert.DoesNotContain(forbidden, AuthoritySeed());
Assert.DoesNotContain(forbidden, IpqcSeed());
Assert.DoesNotContain(forbidden, ProcessSeed());
}
/// membership 迁移是 COPY 不是 MOVE:不得删除既有跨租户绑定。
[Fact]
public void MembershipMigration_CopiesAndNeverDeletesLegacyBinding()
{
var s = AuthoritySeed();
Assert.Contains("CopyLegacyCrossTenantMembership", s);
Assert.DoesNotContain("Deleteable", s);
Assert.DoesNotContain("DeleteAsync", s);
}
/// SysUserRole 没有 (UserId, RoleId) 唯一约束,迁移必须自己防重。
[Fact]
public void MembershipMigration_IsIdempotent()
{
var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId");
Assert.Contains("Any(x => x.UserId == user.Id && x.RoleId == localRoleId.Value)", body);
Assert.Contains("if (bound) continue;", body);
}
/// Activation Gate:成员必须与角色同租户才算 ready,只数绑定行会误判。
[Fact]
public void ActivationGate_RequiresSameTenantMembersForAllThreeRoles()
{
var body = Slice(AuthoritySeed(), "public static List GetActivatableTenantIds", "private static List ResolveTenantIds");
Assert.Contains("u.TenantId == tenantId", body);
Assert.Contains("if (sameTenantMembers == 0)", body);
Assert.Contains("foreach (var spec in RoleSpecs)", body);
}
// ───────────── FlowJson:RoleCode 权威 ─────────────
/// 两条 S6 链的 Role 节点一律用 RoleCode,不得再出现物理 RoleId。
[Theory]
[InlineData("IpqcInspectionFlowConst.RoleInspectorId.ToString()")]
[InlineData("IpqcInspectionFlowConst.RoleSupervisorId.ToString()")]
[InlineData("IpqcInspectionFlowConst.RoleQualityEngineerId.ToString()")]
public void IpqcFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
{
Assert.DoesNotContain(forbidden, IpqcSeed());
}
/// S6_PROCESS_INSPECTION 同样不得再写物理 RoleId。
[Theory]
[InlineData("RoleSupervisorId.ToString()")]
[InlineData("RoleQualityEngineerId.ToString()")]
public void ProcessFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
{
Assert.DoesNotContain(forbidden, ProcessSeed());
}
/// IPQC 三个 Role 节点的 approverIds 必须是三个 RoleCode 常量。
[Theory]
[InlineData("ApproverIds = IpqcInspectionFlowConst.RoleInspectorCode")]
[InlineData("ApproverIds = IpqcInspectionFlowConst.RoleSupervisorCode")]
[InlineData("ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerCode")]
public void IpqcFlowJson_UsesRoleCodeAuthority(string expected)
{
Assert.Contains(expected, IpqcSeed());
}
/// S6_PROCESS_INSPECTION 的 N2/N3 必须是 RoleCode(N1 是 Initiator,不查角色)。
[Theory]
[InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorCode")]
[InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerCode")]
public void ProcessFlowJson_UsesRoleCodeAuthority(string expected)
{
Assert.Contains(expected, ProcessSeed());
}
/// 三个 RoleCode 字面量本身必须稳定,改动会让既有定义与新定义解析到不同角色。
[Fact]
public void RoleCodes_AreStable()
{
var s = File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs"));
Assert.Contains($"RoleInspectorCode = \"{InspectorCode}\"", s);
Assert.Contains($"RoleSupervisorCode = \"{SupervisorCode}\"", s);
Assert.Contains($"RoleQualityEngineerCode = \"{QeCode}\"", s);
}
// ───────────── Definition:tenant-scoped ─────────────
/// 定义副本维度必须是 Tenant 而不是 Org —— 引擎选定义时只看 TenantId、完全不看 OrgId。
[Theory]
[InlineData("Ipqc")]
[InlineData("Process")]
public void Definitions_AreKeyedByTenantNotOrg(string which)
{
var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
var body = Slice(s, "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
Assert.Contains("GetActivatableTenantIds(db)", body);
Assert.Contains("x.TenantId == tenantId", body);
Assert.Contains("TenantId = tenantId,", body);
// 不得再按 SysOrg 逐组织复制
Assert.DoesNotContain("Queryable()", body);
}
/// 新定义必须显式落 TenantId,不得再产生 TenantId 为空的「全局」定义。
[Theory]
[InlineData("Ipqc")]
[InlineData("Process")]
public void Definitions_SetExplicitTenantId(string which)
{
var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
Assert.Contains("TenantId = tenantId,", s);
Assert.DoesNotContain("TenantId = null", s);
}
/// 幂等:已是 RoleCode 版就跳过,不得每次启动都堆一版。
[Theory]
[InlineData("Ipqc")]
[InlineData("Process")]
public void Definitions_AreIdempotentOnRoleCodeMarker(string which)
{
var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
Assert.Contains("RoleSupervisorCode))", s);
Assert.Contains("continue;", s);
}
/// 本批纯增量:不得下架或删除历史定义(legacy 清理属后续独立批次)。
[Theory]
[InlineData("Ipqc")]
[InlineData("Process")]
public void Definitions_NeverUnpublishOrDeleteLegacy(string which)
{
var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
Assert.DoesNotContain("x.IsPublished == false", s);
Assert.DoesNotContain("Deleteable", 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));
}
}