|
|
@@ -0,0 +1,279 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Const.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
|
|
|
+using Microsoft.AspNetCore.Mvc;
|
|
|
+using Microsoft.AspNetCore.Mvc.Filters;
|
|
|
+using Microsoft.AspNetCore.Mvc.Routing;
|
|
|
+using System.Reflection;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-P0-4-API-AUTHORIZATION-1:S8 接口鉴权守卫。
|
|
|
+///
|
|
|
+/// <para>修复前实测(业务账号 <c>UATExceptionA</c>,2026-09-02):
|
|
|
+/// <c>PUT /config/watch-rules/{id}/params</c> 与 <c>POST /config/operator-bindings</c>
|
|
|
+/// 都直达业务校验返回 <b>400</b> 而非 403 —— 鉴权层根本没拦。
|
|
|
+/// 根因是平台 <c>JwtHandler</c> 用「路由路径逐字转权限名」匹配,
|
|
|
+/// 而 S8 是带路径参数的 RESTful 路由(<c>…/exceptions/{id}/claim</c> → <c>…:1329909430019:claim</c>),
|
|
|
+/// 静态权限码永远不可能相等;又因末行「未登记路由默认放行」,全部 S8 接口变成登录即可调用。</para>
|
|
|
+///
|
|
|
+/// <para>本测试全部走**反射元数据**(不是字符串 grep),逐个枚举 S8 Controller 的 Action,
|
|
|
+/// 保证:① 每个 Action 都声明了能力码;② 能力码在正式目录内;
|
|
|
+/// ③ 写动作不得落到只读能力上;④ 各类动作落到正确的能力分组;
|
|
|
+/// ⑤ 不回退到已死的 <c>ado_s8_role_permission_config.permission_codes</c>。</para>
|
|
|
+/// </summary>
|
|
|
+public class S8AuthorizationGuardTests
|
|
|
+{
|
|
|
+ private static readonly Assembly PluginAssembly = typeof(AdoS8ExceptionsController).Assembly;
|
|
|
+
|
|
|
+ /// <summary>S8 命名空间下的全部 Controller。</summary>
|
|
|
+ public static IEnumerable<Type> S8Controllers => PluginAssembly
|
|
|
+ .GetTypes()
|
|
|
+ .Where(t => t.Namespace == typeof(AdoS8ExceptionsController).Namespace
|
|
|
+ && typeof(ControllerBase).IsAssignableFrom(t)
|
|
|
+ && !t.IsAbstract)
|
|
|
+ .OrderBy(t => t.Name);
|
|
|
+
|
|
|
+ public static TheoryData<Type> ControllerData()
|
|
|
+ {
|
|
|
+ var data = new TheoryData<Type>();
|
|
|
+ foreach (var t in S8Controllers) data.Add(t);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ private sealed record ActionInfo(Type Controller, MethodInfo Method, string Verb, string Template, string? Permission);
|
|
|
+
|
|
|
+ private static IEnumerable<ActionInfo> AllActions()
|
|
|
+ {
|
|
|
+ foreach (var c in S8Controllers)
|
|
|
+ {
|
|
|
+ foreach (var m in c.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
|
|
+ {
|
|
|
+ var http = m.GetCustomAttributes().OfType<IActionHttpMethodProvider>().FirstOrDefault();
|
|
|
+ if (http == null) continue;
|
|
|
+
|
|
|
+ var verb = http.HttpMethods.FirstOrDefault() ?? "GET";
|
|
|
+ var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty;
|
|
|
+ var perm = m.GetCustomAttribute<S8PermissionAttribute>()?.Code;
|
|
|
+ yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static bool IsReadCapability(string code) =>
|
|
|
+ code is S8PermissionCatalog.ExceptionRead
|
|
|
+ or S8PermissionCatalog.ConfigRead
|
|
|
+ or S8PermissionCatalog.DashboardRead;
|
|
|
+
|
|
|
+ // ───────────────────────── ① 全量覆盖 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>S8 Controller 必须被枚举到(防止命名空间搬家后本测试静默空跑)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void AllS8ControllersAreDiscovered()
|
|
|
+ {
|
|
|
+ var controllers = S8Controllers.ToList();
|
|
|
+ Assert.True(controllers.Count >= 25, $"仅发现 {controllers.Count} 个 S8 Controller,疑似枚举失效");
|
|
|
+ Assert.Contains(controllers, t => t == typeof(AdoS8ExceptionsController));
|
|
|
+ Assert.Contains(controllers, t => t == typeof(AdoS8WatchDebugController));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>每一个 Action 都必须有确定的授权要求,一个都不许漏。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void EveryS8Action_DeclaresAPermission()
|
|
|
+ {
|
|
|
+ var missing = AllActions()
|
|
|
+ .Where(a => string.IsNullOrWhiteSpace(a.Permission))
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]")
|
|
|
+ .ToList();
|
|
|
+
|
|
|
+ Assert.True(missing.Count == 0,
|
|
|
+ "以下 S8 Action 未声明 [S8Permission],将退回平台『未登记路由默认放行』:\n" + string.Join('\n', missing));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void EveryDeclaredPermission_ExistsInCatalog()
|
|
|
+ {
|
|
|
+ var known = S8PermissionCatalog.All.Select(x => x.Code).ToHashSet(StringComparer.Ordinal);
|
|
|
+ var unknown = AllActions()
|
|
|
+ .Where(a => a.Permission != null && !known.Contains(a.Permission))
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
|
|
|
+ .Distinct().ToList();
|
|
|
+
|
|
|
+ Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── ② 写动作不得落到只读能力 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 所有 mutation(非 GET)Action 都不得使用只读能力码,
|
|
|
+ /// 否则「有查看权限的人」就能改配置 / 改业务状态。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void MutationActions_DoNotUseReadOnlyCapabilities()
|
|
|
+ {
|
|
|
+ var offenders = AllActions()
|
|
|
+ .Where(a => a.Verb != "GET" && a.Permission != null && IsReadCapability(a.Permission))
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission}")
|
|
|
+ .ToList();
|
|
|
+
|
|
|
+ Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── ③ 分组正确性 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>配置类 Controller 的写动作必须落在 config 组,不得使用 operator / verification 能力。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData(typeof(AdoS8ConfigWatchRulesController), S8PermissionCatalog.ConfigWatchRule)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigDataSourcesController), S8PermissionCatalog.ConfigDataSource)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigExceptionTypesController), S8PermissionCatalog.ConfigExceptionType)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigNotificationLayersController), S8PermissionCatalog.ConfigNotification)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigScenesController), S8PermissionCatalog.ConfigScene)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigKpiTargetsController), S8PermissionCatalog.ConfigKpi)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigDashboardCellsController), S8PermissionCatalog.ConfigDashboard)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigBindingsController), S8PermissionCatalog.ConfigOperatorBind)]
|
|
|
+ [InlineData(typeof(AdoS8ConfigRolesController), S8PermissionCatalog.ConfigRoleWrite)]
|
|
|
+ public void ConfigController_MutationsUseItsOwnConfigCapability(Type controller, string expected)
|
|
|
+ {
|
|
|
+ var mutations = AllActions().Where(a => a.Controller == controller && a.Verb != "GET").ToList();
|
|
|
+ Assert.NotEmpty(mutations);
|
|
|
+ Assert.All(mutations, a => Assert.Equal(expected, a.Permission));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>配置类 Controller 一律不得出现 operator / verification 能力码。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void ConfigControllers_NeverUseOperatorOrQualityCapabilities()
|
|
|
+ {
|
|
|
+ var operatorOrQuality = new[]
|
|
|
+ {
|
|
|
+ S8PermissionCatalog.ExceptionClaim, S8PermissionCatalog.ExceptionStart,
|
|
|
+ S8PermissionCatalog.ExceptionAssign, S8PermissionCatalog.ExceptionUpgrade,
|
|
|
+ S8PermissionCatalog.ExceptionReject, S8PermissionCatalog.ExceptionClose,
|
|
|
+ S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove,
|
|
|
+ S8PermissionCatalog.VerificationReject,
|
|
|
+ }.ToHashSet(StringComparer.Ordinal);
|
|
|
+
|
|
|
+ var offenders = AllActions()
|
|
|
+ .Where(a => a.Controller.Name.Contains("Config", StringComparison.Ordinal)
|
|
|
+ && a.Permission != null && operatorOrQuality.Contains(a.Permission))
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
|
|
|
+ .ToList();
|
|
|
+
|
|
|
+ Assert.True(offenders.Count == 0, string.Join('\n', offenders));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("approve-verification", S8PermissionCatalog.VerificationApprove)]
|
|
|
+ [InlineData("reject-verification", S8PermissionCatalog.VerificationReject)]
|
|
|
+ [InlineData("submit-verification", S8PermissionCatalog.VerificationSubmit)]
|
|
|
+ [InlineData("claim", S8PermissionCatalog.ExceptionClaim)]
|
|
|
+ [InlineData("start-progress", S8PermissionCatalog.ExceptionStart)]
|
|
|
+ [InlineData("transfer", S8PermissionCatalog.ExceptionAssign)]
|
|
|
+ [InlineData("upgrade", S8PermissionCatalog.ExceptionUpgrade)]
|
|
|
+ [InlineData("comment", S8PermissionCatalog.ExceptionComment)]
|
|
|
+ public void ExceptionAction_MapsToExpectedCapability(string routeFragment, string expected)
|
|
|
+ {
|
|
|
+ var action = AllActions().SingleOrDefault(a =>
|
|
|
+ a.Controller == typeof(AdoS8ExceptionsController)
|
|
|
+ && a.Verb != "GET"
|
|
|
+ && a.Template.EndsWith(routeFragment, StringComparison.Ordinal));
|
|
|
+
|
|
|
+ Assert.NotNull(action);
|
|
|
+ Assert.Equal(expected, action!.Permission);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void QualityApproval_IsSeparatedFromOperatorSubmission()
|
|
|
+ {
|
|
|
+ Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove);
|
|
|
+ Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationReject);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── ④ 调试接口 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 调试 / 运维接口必须要求独立的 debug 能力,且不得复用任何业务能力码。
|
|
|
+ /// 该接口历史上是「任意登录用户可调用」,且能对**任意 tenantId/factoryId** 触发自动建单主链。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void DebugEndpoints_RequireDedicatedDebugCapability()
|
|
|
+ {
|
|
|
+ var actions = AllActions().Where(a => a.Controller == typeof(AdoS8WatchDebugController)).ToList();
|
|
|
+ Assert.NotEmpty(actions);
|
|
|
+ Assert.All(actions, a => Assert.Equal(S8PermissionCatalog.DebugRun, a.Permission));
|
|
|
+
|
|
|
+ // debug 能力码不得被任何业务接口复用。
|
|
|
+ var leaked = AllActions()
|
|
|
+ .Where(a => a.Permission == S8PermissionCatalog.DebugRun
|
|
|
+ && a.Controller != typeof(AdoS8WatchDebugController))
|
|
|
+ .Select(a => a.Controller.Name + "." + a.Method.Name)
|
|
|
+ .ToList();
|
|
|
+ Assert.True(leaked.Count == 0, string.Join('\n', leaked));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── ⑤ 不回退到死表 ─────────────────────────
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 授权判定必须走平台 SysMenu 按钮权限(<c>SysMenuService.GetOwnBtnPermList</c>),
|
|
|
+ /// 不得把已确认 0 消费方的 <c>ado_s8_role_permission_config.permission_codes</c> 接回运行链。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void AuthorizationDoesNotRevivePermissionCodesTable()
|
|
|
+ {
|
|
|
+ var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
|
|
|
+ var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
|
|
|
+
|
|
|
+ Assert.Contains("GetOwnBtnPermList", src);
|
|
|
+ Assert.DoesNotContain("AdoS8RolePermissionConfig", src);
|
|
|
+ Assert.DoesNotContain("PermissionCodes", src);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void PermissionGate_IsFailClosed()
|
|
|
+ {
|
|
|
+ var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
|
|
|
+ var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
|
|
|
+
|
|
|
+ Assert.Contains("Status403Forbidden", src);
|
|
|
+ Assert.Contains("catch", src);
|
|
|
+ // 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。
|
|
|
+ Assert.Throws<ArgumentException>(() => new S8PermissionAttribute(""));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 目录内每个**未废弃**能力码都应至少被一个 Action 使用,避免目录与实现漂移出孤儿码。
|
|
|
+ /// (<c>s8:exception:close</c> 已标 deprecated:S8 无独立关闭接口,关闭由 approve-verification
|
|
|
+ /// 经状态机达成;既有 SysMenu 行与授权保持不动,故不从目录删除。)
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void CatalogHasNoOrphanCapability()
|
|
|
+ {
|
|
|
+ var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal);
|
|
|
+ var orphans = S8PermissionCatalog.All
|
|
|
+ .Where(x => !x.Deprecated)
|
|
|
+ .Select(x => x.Code)
|
|
|
+ .Where(c => !used.Contains(c))
|
|
|
+ .ToList();
|
|
|
+ Assert.True(orphans.Count == 0, "以下能力码在目录内但无任何 Action 使用:\n" + string.Join('\n', orphans));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void DeprecatedCapabilitiesAreNotReferencedByAnyAction()
|
|
|
+ {
|
|
|
+ var deprecated = S8PermissionCatalog.All.Where(x => x.Deprecated).Select(x => x.Code)
|
|
|
+ .ToHashSet(StringComparer.Ordinal);
|
|
|
+ Assert.NotEmpty(deprecated); // 防止标记体系被误删后本测试空跑
|
|
|
+
|
|
|
+ var offenders = AllActions()
|
|
|
+ .Where(a => a.Permission != null && deprecated.Contains(a.Permission))
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
|
|
|
+ .ToList();
|
|
|
+ Assert.True(offenders.Count == 0, string.Join('\n', offenders));
|
|
|
+ }
|
|
|
+}
|