using Admin.NET.Plugin.AiDOP.Const.S8;
using Admin.NET.Plugin.AiDOP.Controllers.S8;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
using System.Reflection;
using System.Text.RegularExpressions;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-DEBUG-TENANT-GUARD-1: 的租户信任边界。
///
/// 修复前的事实(2026-09-20 只读审计):本 Controller 是 S8 中唯一把 query 上的
/// tenantId 当执行上下文的入口。run-once 把它直接传给
/// S8WatchSchedulerService.CreateExceptionsAsync(tenantId),可在任意租户建单,
/// 并从出参读回该租户的 createdExceptionId / relatedObjectCode / matchedExceptionId;
/// test-cleanup 可软删任意租户的 TEST_G09_ 数据。唯一的阻挡是
/// WatchScheduler:DebugEndpointEnabled —— 一个功能开关,不是信任边界。
///
/// 为什么是源码契约测试而不是运行时测试:本 Controller 依赖
/// S8WatchSchedulerService(需要 SqlSugar 实库)与 S8TrustedScopeResolver(需要
/// 认证后的 UserManager),在进程内无法构造 —— 与
/// S8ExceptionFlowTenantContextContractTests 同一处境,沿用同一手法:
/// 反射元数据 + 去注释后的源码契约。本测试不断言运行时会返回 403/404,
/// 它断言的是「代码里不存在那条跨租户通路」。
///
/// 断言一律在剥离注释后的源码上进行:否则文档里提到 tenantId 就会把
/// 守卫变成噪音,或在有人改文案时假失败。
///
public class S8DebugTenantGuardTests
{
private static string PluginRoot => Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string ReadCode(string relativePath)
{
var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
// 先删块注释,再删行注释(含 /// 文档注释)。字符串字面量里不含 "//",本文件范围内安全。
var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
}
private static string DebugControllerCode => ReadCode("Controllers/S8/AdoS8WatchDebugController.cs");
private static int Count(string haystack, string needle)
{
var n = 0;
for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) n++;
return n;
}
private static IEnumerable DebugActions => typeof(AdoS8WatchDebugController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => !m.IsSpecialName);
// ───────────────── ① 当前租户可正常执行 ─────────────────
///
/// 两个 Action 都必须从可信作用域取租户并据此执行。
/// 这既是「当前租户可用」的正面断言,也是租户来源的唯一合法形态。
///
[Fact]
public void CurrentTenant_DebugExecution_UsesResolvedScope()
{
var code = DebugControllerCode;
Assert.Equal(2, Count(code, "_scope.ResolveAsync()"));
Assert.Contains("CreateExceptionsAsync(scope.TenantId)", code);
Assert.Contains("SoftDeleteTestPrefixAsync(scope.TenantId)", code);
}
/// 可信作用域解析器必须由构造函数注入 —— 反射元数据,不可被改文案绕过。
[Fact]
public void Controller_InjectsTrustedScopeResolver()
{
var ctor = Assert.Single(typeof(AdoS8WatchDebugController).GetConstructors());
Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(S8TrustedScopeResolver));
}
// ───────────────── ② 客户端传入的 tenantId 不得切换执行上下文 ─────────────────
///
/// query 上的 tenantId / factoryId 只能被显式丢弃,绝不能流入任何 service 调用。
///
[Fact]
public void ClientSuppliedOtherTenant_CannotSwitchTenant()
{
var code = DebugControllerCode;
// 兼容形参必须被显式丢弃(run-once 的 tenantId+factoryId、test-cleanup 的 tenantId)。
Assert.Equal(2, Count(code, "_ = tenantId;"));
Assert.Contains("_ = factoryId;", code);
// 修复前的两条跨租户通路,必须不复存在。
Assert.DoesNotContain("CreateExceptionsAsync(tenantId)", code);
Assert.DoesNotContain("SoftDeleteTestPrefixAsync(tenantId", code);
// 不得以任何形式把 query 参数重新当作租户使用。
Assert.DoesNotContain("tenantId.Value", code);
Assert.DoesNotContain("tenantId ??", code);
}
///
/// ③/④ 合并断言:读回(run-once 出参)与写入(建单 / 软删)都只能发生在已解析租户上。
/// 两个 service 调用是本 Controller 仅有的数据出入口,全部以 scope.TenantId 为准,
/// 因此不存在「读到别家租户 exception」或「在别家租户建单」的通路。
///
[Fact]
public void DebugReadAndWrite_AreConfinedToResolvedTenant()
{
var code = DebugControllerCode;
var serviceCalls = new[] { "CreateExceptionsAsync(", "SoftDeleteTestPrefixAsync(" };
foreach (var call in serviceCalls)
{
Assert.Equal(1, Count(code, call));
var idx = code.IndexOf(call, StringComparison.Ordinal);
var arg = code[(idx + call.Length)..code.IndexOf(')', idx)];
Assert.Equal("scope.TenantId", arg.Trim());
}
}
// ───────────────── ⑤ 未启用时的拒绝语义 ─────────────────
///
/// DebugEndpointEnabled=false 时两个 Action 都必须 404,且该门必须在
/// 作用域解析与任何 service 调用之前返回(DB 零触达)。
///
[Fact]
public void DebugDisabled_ReturnsNotFound_BeforeAnyWork()
{
var code = DebugControllerCode;
const string gate = "if (!_debugEndpointEnabled) return NotFound();";
Assert.Equal(2, Count(code, gate));
// 逐个 Action 校验顺序:第 n 道门必须早于第 n 次作用域解析。
var gateIdx = -1;
var scopeIdx = -1;
for (var i = 0; i < 2; i++)
{
gateIdx = code.IndexOf(gate, gateIdx + 1, StringComparison.Ordinal);
scopeIdx = code.IndexOf("_scope.ResolveAsync()", scopeIdx + 1, StringComparison.Ordinal);
Assert.True(gateIdx >= 0 && scopeIdx >= 0 && gateIdx < scopeIdx,
$"第 {i + 1} 个 Action 的 DebugEndpointEnabled 门禁必须早于作用域解析。");
}
// 默认值必须是 false(fail-closed):缺配置时不得默认开启。
Assert.Contains("GetValue(\"WatchScheduler:DebugEndpointEnabled\", false)", code);
}
// ───────────────── ⑥ 超管:沿用平台既有策略,不自行放权 ─────────────────
///
/// 本 Controller 不得出现任何超管特判 —— 调试入口不是新开全租户权限的地方。
/// 超管口径完全由 决定。
///
[Fact]
public void SuperAdmin_GetsNoCrossTenantDebugPrivilegeHere()
{
var code = DebugControllerCode;
Assert.DoesNotContain("SuperAdmin", code);
Assert.DoesNotContain("ClearFilter", code);
Assert.DoesNotContain("IgnoreQueryFilters", code);
}
///
/// 平台既有超管策略必须仍然是「未选目标租户 → 直接拒绝」(保守拒绝,不默认全租户)。
/// 本批不改这条策略,只确认调试入口继承的是它。
///
[Fact]
public void TenantAuthority_StillRejectsSuperAdminWithoutTargetTenant()
{
var authority = ReadCode("Infrastructure/AidopTenantScope.cs");
Assert.Contains("userManager.SuperAdmin && tid == MainTenantId", authority);
Assert.Contains("throw Oops.Oh", authority);
Assert.Contains("if (tid <= 0)", authority);
}
// ───────────────── 单一 authority:不得新增第二套租户解析 ─────────────────
///
/// 调试入口必须复用 S8 统一的租户 authority,不得自建第二套
/// (直接读 UserManager / 从配置或请求头取租户都算)。
///
[Fact]
public void DebugEndpoint_DoesNotIntroduceSecondTenantAuthority()
{
var code = DebugControllerCode;
Assert.Contains("S8TrustedScopeResolver", code);
Assert.DoesNotContain("UserManager", code);
Assert.DoesNotContain("HttpContext", code);
Assert.DoesNotContain("Request.Headers", code);
}
/// 两个 Action 仍须挂 s8:debug:run 能力门(本批不得顺手放松授权)。
[Fact]
public void DebugActions_StillRequireDebugRunPermission()
{
var actions = DebugActions.ToList();
Assert.Equal(2, actions.Count);
Assert.All(actions, m =>
{
var attr = m.GetCustomAttribute();
Assert.NotNull(attr);
Assert.Equal(S8PermissionCatalog.DebugRun, attr!.Code);
});
}
}