using System.Reflection; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-ACTION-PERMISSION-1:异常单动作授权模型。 /// /// 本批修的是一个实测存在的跨租户权限泄漏。平台的 /// SysMenuService.GetOwnBtnPermList() 只对菜单做租户校验 /// (InnerJoin SysTenantMenu),对角色不做SysUserRole 也不要求 /// 用户与角色同租户。真库取证:UATAdminA(租户 838257186181189)经 /// ROLE_S6_IPQC_SUPERVISORSysRole.TenantId = 1300000000001) /// 拿到 s8:exception:assign 等能力,而它自己租户里没有任何角色授予这些能力。 /// /// 因此本文件的断言大量是反向的:这类缺陷的特征是「加回一个谓词 / 少一个 /// 租户过滤就静默放宽」,正向断言抓不到。 /// public class S8ExceptionActionPermissionTests { private const long TenantA = 838257186181189L; private const long TenantB = 838257212780613L; private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); /// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 private static string CodeOnly(string relativePath) { var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return string.Join('\n', File.ReadAllLines(full) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } // ══════════════════════════════════════════════════════════════════════════════ // 动作目录:只能有真实存在的动作 // ══════════════════════════════════════════════════════════════════════════════ /// /// 目录里的每个动作都必须标注它对应的真实接口。 /// /// 顺带守住一条设计约束:不得出现 EXCEPTION_CLOSE。 /// S8 没有独立的「关闭」接口 —— 关闭是 approve-verification 通过后的状态机结果。 /// 给它一个可配置权限位,管理员会配一个永远不被检查的开关。 /// [Fact] public void ActionCatalog_ContainsOnlyRealActions() { Assert.NotEmpty(S8ExceptionActionCatalog.All); foreach (var d in S8ExceptionActionCatalog.All) { Assert.False(string.IsNullOrWhiteSpace(d.Code)); Assert.False(string.IsNullOrWhiteSpace(d.DisplayName)); Assert.False(string.IsNullOrWhiteSpace(d.ApiHint)); } Assert.DoesNotContain(S8ExceptionActionCatalog.All, d => d.Code.Equals("EXCEPTION_CLOSE", StringComparison.OrdinalIgnoreCase)); // 动作码唯一 Assert.Equal(S8ExceptionActionCatalog.All.Count, S8ExceptionActionCatalog.All.Select(d => d.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count()); } /// /// 目录必须完整覆盖异常控制器上的每一个 Action —— 漏一个就意味着那个接口没有授权门。 /// [Fact] public void EveryExceptionEndpoint_IsGuardedByActionAttribute() { foreach (var controller in new[] { typeof(AdoS8ExceptionsController), typeof(AdoS8ReportsController) }) { var actions = controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) .Where(m => !m.IsSpecialName) .ToList(); Assert.NotEmpty(actions); foreach (var a in actions) { var attr = a.GetCustomAttribute(); Assert.True(attr != null, $"{controller.Name}.{a.Name} 缺少 S8ExceptionAction 授权门"); Assert.True(S8ExceptionActionCatalog.IsKnown(attr!.Code), $"{controller.Name}.{a.Name} 使用了目录外的动作码 {attr.Code}"); // 旧门禁不得与新门禁并存 —— 同一问题两个 authority 迟早判据分叉。 Assert.Null(a.GetCustomAttribute()); } } } /// 未知动作码在构造期就必须炸,而不是等运行时静默放行。 [Fact] public void UnknownAction_RejectedAtConstruction() { Assert.Throws(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_A_REAL_ACTION")); Assert.Throws(() => new S8ExceptionActionAttribute("")); Assert.False(S8ExceptionActionCatalog.IsKnown("EXCEPTION_NOT_A_REAL_ACTION")); Assert.False(S8ExceptionActionCatalog.IsKnown(null)); } // ══════════════════════════════════════════════════════════════════════════════ // 授权器判据 // ══════════════════════════════════════════════════════════════════════════════ /// /// T-unknown-action / T-fail-closed: /// 未知动作拒绝;该动作一个角色都没配也必须拒绝。 /// /// 「配置表为空 = 所有人拥有全部权限」是权限系统最典型的事故 —— /// 上线时表还没灌数据,于是全员拥有全部动作,而且没有任何报错。 /// [Fact] public void Authorizer_FailsClosed_OnUnknownActionAndEmptyConfig() { var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs"); // 未知动作先判、直接拒 Assert.Contains("if (!S8ExceptionActionCatalog.IsKnown(actionCode))", code); Assert.Contains(S8ActionDenyReason.UnknownAction, code); // 空配置 = 拒绝 Assert.Contains("if (grantedRoleIds.Count == 0)", code); Assert.Contains(S8ActionDenyReason.ActionNotConfigured, code); // 不得出现任何「空即放行」的写法 Assert.DoesNotContain("Count == 0) return S8ActionAuthorizationResult.Pass()", code); } /// /// T-cross-tenant-role-reject:账号持有的角色必须属于当前租户。 /// /// 授权器不自己查用户角色,而是走 —— /// 跨租户角色在那一步就已经被排除,授权器这一侧不可能再漏。 /// [Fact] public void Authorizer_ResolvesRolesThroughTenantScopedResolver() { var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs"); Assert.Contains("_roleResolver.GetRoleIdsAsync(tenantId, userId)", code); // 不得绕过 resolver 自己拼 SysUserRole 查询 Assert.DoesNotContain("Queryable", code); Assert.DoesNotContain("SysRoleMenu", code); } /// 租户角色解析器本身必须带 SysRole.TenantId == 传入租户,且不含工厂谓词。 [Fact] public void TenantRoleResolver_IsTenantScopedAndFactoryFree() { var code = CodeOnly("Infrastructure/S8/S8TenantRoleResolver.cs"); Assert.Contains("r.TenantId == tenantId", code); Assert.DoesNotContain("FactoryId", code); Assert.DoesNotContain("FactoryRefId", code); Assert.DoesNotContain("EmployeeMaster", code); } /// /// T-factory-irrelevant / T-no-employee: /// 授权链上任何一环都不得出现工厂或员工。 /// [Theory] [InlineData("Infrastructure/S8/S8ExceptionActionAuthorizer.cs")] [InlineData("Infrastructure/S8/S8TenantRoleResolver.cs")] [InlineData("Infrastructure/S8/S8ExceptionActionAttribute.cs")] [InlineData("Service/S8/S8ExceptionActionRoleService.cs")] [InlineData("Service/S8/S8ExceptionActionRoleProvisioningService.cs")] public void AuthorizationChain_HasNoFactoryOrEmployee(string relativePath) { var code = CodeOnly(relativePath); Assert.DoesNotContain("FactoryRefId", code); Assert.DoesNotContain("EmployeeMaster", code); Assert.DoesNotContain("AdoS0EmployeeMaster", code); } /// /// T-multiple-roles-union:多角色取并集 —— 持有任一被授权角色即通过, /// 而不是要求全部持有。 /// [Fact] public void Authorizer_UnionsMultipleRoles() { var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs"); // Any(...) = 并集语义;若写成 All(...) 就变成"必须持有全部角色" Assert.Contains("userRoleIds.Any(grantedRoleIds.Contains)", code); Assert.DoesNotContain("userRoleIds.All(", code); } /// 授权判定必须只有一份实现;各 Service 不得自己散落角色判断。 [Fact] public void ActionAuthorization_HasSingleImplementation() { var impls = typeof(IS8ExceptionActionAuthorizer).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IS8ExceptionActionAuthorizer).IsAssignableFrom(t)) .ToList(); Assert.Single(impls); Assert.Equal(typeof(S8ExceptionActionAuthorizer), impls[0]); var resolvers = typeof(IS8TenantRoleResolver).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IS8TenantRoleResolver).IsAssignableFrom(t)) .ToList(); Assert.Single(resolvers); // 业务 Service 里不得再出现自己的角色/权限判断 foreach (var f in new[] { "Service/S8/S8TaskFlowService.cs", "Service/S8/S8ExceptionService.cs", "Service/S8/S8ManualReportService.cs", }) { var code = CodeOnly(f); Assert.DoesNotContain("SysRoleMenu", code); Assert.DoesNotContain("GetOwnBtnPermList", code); } } // ══════════════════════════════════════════════════════════════════════════════ // 状态机仍然独立生效 // ══════════════════════════════════════════════════════════════════════════════ /// /// T-state-rule-still-enforced:权限通过不等于状态允许。 /// 两道门必须各自独立 —— 权限只回答「这个角色能不能做这类事」, /// 状态回答「这张单现在能不能做」。 /// [Fact] public void StateMachine_RemainsIndependentOfPermission() { var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); Assert.Contains("S8StatusRules.IsAllowedTransition", code); // 状态判断不得被改写成"有权限就跳过状态检查" Assert.DoesNotContain("IsAllowedTransition(e.Status, \"ASSIGNED\") ||", code); // 认领的原子谓词仍在(First Claim Wins 不因权限改造而失效) Assert.Contains("x.AssigneeUserId == null", code); Assert.Contains("该异常已被其他人员认领", code); } // ══════════════════════════════════════════════════════════════════════════════ // 写入侧 & 供给 // ══════════════════════════════════════════════════════════════════════════════ /// 写入侧必须校验角色属于当前租户,否则刚堵的洞会从配置页漏回来。 [Fact] public void Write_RejectsCrossTenantRole() { var code = CodeOnly("Service/S8/S8ExceptionActionRoleService.cs"); Assert.Contains("r.TenantId == scope.TenantId", code); Assert.Contains("所选角色不存在或不属于当前租户", code); } /// /// 供给只推导本租户角色,且推不出角色时留空(fail closed),不给兜底角色。 /// [Fact] public void Provisioning_DerivesFromSameTenantRolesOnly() { var code = CodeOnly("Service/S8/S8ExceptionActionRoleProvisioningService.cs"); Assert.Contains("pending.Contains(r.TenantId.Value)", code); Assert.Contains("if (roleIds.Count == 0) { result.UnconfiguredCount++; continue; }", code); // 只做首次供给:已有任意授权行的租户整体跳过,避免把管理员取消的授权悄悄加回来 Assert.Contains("tenantsWithRows", code); } /// 存储身份是 (租户, 动作, 角色),不含工厂;且只存 Action↔Role,不存 Action↔User。 [Fact] public void Storage_IdentityIsTenantActionRole() { var t = typeof(AdoS8ExceptionActionRole); Assert.NotNull(t.GetProperty("TenantId")); Assert.NotNull(t.GetProperty("ActionCode")); Assert.NotNull(t.GetProperty("RoleId")); Assert.Null(t.GetProperty("FactoryId")); Assert.Null(t.GetProperty("UserId")); } /// 迁移必须建唯一索引并断言"不存在跨租户角色授权"。 [Fact] public void Migration_GuardsUniquenessAndCrossTenant() { var sql = File.ReadAllText(Path.GetFullPath(Path.Combine( PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.497.sql"))); Assert.Contains("uk_s8_action_role", sql); Assert.Contains("s8_497_BLOCKED_unique_index_missing", sql); Assert.Contains("s8_497_BLOCKED_cross_tenant_role_granted", sql); // 迁移前的跨租户授予必须留证,否则明天没人能区分"修复"与"回归" Assert.Contains("ado_s8_cross_tenant_grant_1_0_497", sql); // AutoVersionUpdate 对含该关键字的脚本一律拒绝执行(朴素子串匹配,注释也算) Assert.DoesNotContain("DELIMITER", sql); } /// 配置面的旧门禁也必须补上租户交集 —— 同一个洞不能只堵一半。 [Fact] public void ConfigPermissionGate_AlsoIntersectsTenantRoles() { var code = CodeOnly("Infrastructure/S8/S8PermissionAttribute.cs"); Assert.Contains("IS8TenantRoleResolver", code); Assert.Contains("GetPermissionCodesAsync", code); // 交集只可能收窄;不得出现"任一满足即放行"的或逻辑 Assert.Contains("if (tenantCodes.Contains(Code)) return;", code); } }