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_SUPERVISOR(SysRole.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);
}
}