using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
///
/// S6-TENANT-AUTHORITY-1 源码契约测试(Phase E · Resolver 守卫)。
///
/// 与 分文件:迁移(角色 / 成员 / 定义)
/// 与守卫是**两个可独立发布的阶段** —— 守卫必须在迁移完成并通过 Runtime 验证之后才允许启用,
/// 否则会把仍靠 legacy 跨租户 RoleId 工作的租户当场打成 0 审批人。拆开后两个提交各自自洽。
///
/// 守卫的范围铁律:只覆盖 IPQC_INSPECTION / S6_PROCESS_INSPECTION。
/// S5_IQC_INSPBILL / S7_FQC_INSPBILL 等同样写死了默认租户物理 RoleId,但它们当前正靠跨租户
/// SysUserRole 绑定工作(实测 28 个节点组合),本批不在迁移范围,无条件启用会直接打断它们。
///
/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowEngineService 依赖
/// SqlSugarRepository<T>,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
///
public class S6TenantAuthorityGuardContractTests
{
private static string Engine() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
// ───────────── Resolver 守卫:S6 专属 fail-closed ─────────────
/// 守卫必须只覆盖两条 S6 链。
[Fact]
public void Guard_ScopedToTheTwoS6BizTypes()
{
var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
Assert.Contains("\"IPQC_INSPECTION\"", set);
Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
}
/// S5 / S7 不得被本批守卫波及——它们仍持默认租户物理 RoleId 且当前正靠它工作。
[Theory]
[InlineData("S5_IQC_INSPBILL")]
[InlineData("S7_FQC_INSPBILL")]
[InlineData("SMART_OPS_IMPROVEMENT")]
[InlineData("EXCEPTION_REPORT")]
public void Guard_DoesNotAffectOtherBizTypes(string bizType)
{
var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
Assert.DoesNotContain(bizType, set);
}
/// 守卫必须是精确白名单查表,不得退化成前缀/模糊匹配把无关业务一起挡掉。
[Fact]
public void Guard_UsesExactAllowList_NotFuzzyMatch()
{
var s = Engine();
Assert.Contains("TenantStrictRoleBizTypes.Contains(bizType)", s);
Assert.DoesNotContain("BizType.StartsWith(\"S6", s);
Assert.DoesNotContain("BizType.Contains(\"IPQC", s);
}
/// 守卫必须在 Code 解析之前跑,针对的是 FlowJson 里写死的数字 RoleId。
[Fact]
public void Guard_RunsBeforeRoleCodeResolution()
{
var s = Engine();
var guardAt = s.IndexOf("await EnsureRoleAuthorityTenantScopedAsync(ids, effectiveTenantId, authorityContext);", StringComparison.Ordinal);
var codeAt = s.IndexOf("codeRoleIds", StringComparison.Ordinal);
Assert.True(guardAt > 0, "未找到守卫调用点");
Assert.True(codeAt > guardAt, "守卫必须先于 RoleCode 解析执行");
}
/// 跨租户 RoleId 必须 fail-closed;且刻意不做 auto-heal(不按 Code 隐式替换)。
[Fact]
public void Guard_FailsClosedOnCrossTenantRole_AndDoesNotAutoHeal()
{
var body = Slice(Engine(),
"private async Task EnsureRoleAuthorityTenantScopedAsync",
"/// \n /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1");
Assert.Contains("r.TenantId != effectiveTenantId", body);
Assert.Contains("throw Oops.Oh(", body);
// auto-heal 会让坏定义继续成功运行、永远没人去修
Assert.DoesNotContain("codes.Contains(r.Code)", body);
Assert.DoesNotContain("roleIds.Add(", body);
}
/// 三类失败必须可区分,且日志带全部定位字段。
[Theory]
[InlineData("CROSS_TENANT_ROLE_REFERENCE")]
[InlineData("INVALID_ROLE")]
[InlineData("LOCAL_ROLE_HAS_NO_MEMBER")]
public void Guard_ClassifiesFailures(string classification)
{
Assert.Contains(classification, Engine());
}
/// 日志必须能定位到具体定义与节点。
[Fact]
public void Guard_LogsDiagnosticFields()
{
var s = Engine();
foreach (var field in new[] { "BizType={BizType}", "FlowId={FlowId}", "NodeId={NodeId}", "RoleRef={RoleRef}", "EffectiveTenantId={TenantId}" })
Assert.Contains(field, s);
}
/// Branch B 必须保留——历史定义仍可能存 RoleId,本批不是删除 RoleId 支持。
[Fact]
public void Guard_KeepsRoleIdBranch()
{
var s = Engine();
Assert.Contains("var userIds = await _userRoleRep.AsQueryable()", s);
Assert.Contains("ids.Contains(ur.RoleId)", s);
}
/// RoleCode 分支(目标路径)必须始终按当前租户 + 启用状态解析,本批不得改动它。
[Fact]
public void RoleCodeBranch_StaysTenantScoped()
{
var s = Engine();
Assert.Contains("r.TenantId == effectiveTenantId", s);
Assert.Contains("r.Status == StatusEnum.Enable", s);
Assert.Contains("codes.Contains(r.Code)", s);
}
/// 成员过滤这道租户收口不得被移除——它是跨租户泄漏的最后一道闸。
[Fact]
public void MemberResolution_KeepsTenantScope()
{
Assert.Contains("u.TenantId == effectiveTenantId", Engine());
}
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));
}
}