using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// 包 G 的越权守卫。测试项目不连库、没有认证夹具,所以用源文本扫描钉住三条不变式: /// 每个 admin 接口都拒绝非超管、都不走租户解析、都复用包 B 的指派写入侧。 /// public class MdpMonitorAdminGuardTests { private static string MonitorSource() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md"))) dir = dir.Parent; Assert.NotNull(dir); return File.ReadAllText(Path.Combine( dir!.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Order", "MdpMonitorService.cs")); } private static string RebuildSource() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md"))) dir = dir.Parent; return File.ReadAllText(Path.Combine( dir!.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpRebuild", "ModuleRebuildService.cs")); } private static string WorkerSource() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md"))) dir = dir.Parent; return File.ReadAllText(Path.Combine( dir!.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpRebuild", "ModuleRebuildWorker.cs")); } /// 切出每个 admin 路由方法的方法体。 private static IEnumerable AdminMethodBodies(string source) { var marker = "mdp-monitor/admin/"; var from = 0; while (true) { var at = source.IndexOf(marker, from, StringComparison.Ordinal); if (at < 0) yield break; var open = source.IndexOf('{', at); var depth = 0; var end = open; for (; end < source.Length; end++) { if (source[end] == '{') depth++; else if (source[end] == '}' && --depth == 0) break; } yield return source[open..(end + 1)]; from = end + 1; } } [Fact] public void AdminEndpoints_AllCheckSuperAdmin() { var bodies = AdminMethodBodies(MonitorSource()).ToList(); Assert.True(bodies.Count >= 6, $"admin 接口数量不符:{bodies.Count}"); foreach (var body in bodies) Assert.Contains("RequireSuperAdmin()", body); } [Fact] public void AdminEndpoints_DoNotCallResolveOrThrow() { foreach (var body in AdminMethodBodies(MonitorSource())) Assert.DoesNotContain("ResolveOrThrow", body); } [Fact] public void LegacyEndpoints_StillCallResolveOrThrow() { var source = MonitorSource(); foreach (var route in new[] { "mdp-monitor/outbound-gate", "mdp-monitor/ship-recon", "mdp-monitor/latest", "mdp-monitor/list", "mdp-monitor/detail", "mdp-monitor/lineage" }) { var at = source.IndexOf($"\"{route}", StringComparison.Ordinal); Assert.True(at >= 0, $"原有路由丢失:{route}"); var window = source[at..Math.Min(source.Length, at + 600)]; Assert.Contains("ResolveOrThrow", window); } } [Fact] public void BuildMdpRunLogTenantWhere_Unchanged() { Assert.Equal("(tenant_id = @TenantId OR tenant_id = 0)", Admin.NET.Plugin.AiDOP.Order.MdpMonitorService.BuildMdpRunLogTenantWhere(9)); Assert.Equal("tenant_id = 0", Admin.NET.Plugin.AiDOP.Order.MdpMonitorService.BuildMdpRunLogTenantWhere(0)); } [Fact] public void CancelCheck_IsInProgressCallback_NotWorker() { Assert.Contains("IsCancelRequestedAsync", RebuildSource()); Assert.DoesNotContain("IsCancelRequestedAsync", WorkerSource()); Assert.DoesNotContain("cancel_requested", WorkerSource()); } [Fact] public void CancelledCatch_PrecedesGenericCatch() { var source = RebuildSource(); var specific = source.IndexOf("catch (ModuleRebuildCancelledException)", StringComparison.Ordinal); Assert.True(specific >= 0, "缺少取消异常的 catch"); var generic = source.IndexOf("catch (Exception ex)", specific, StringComparison.Ordinal); Assert.True(generic > specific, "取消异常的 catch 必须早于通用 catch,否则会被吞成 FAILED"); } /// /// 指派必须委托给 IEtlInstanceStore,控制器不得自己写指派。 /// /// 2026-09-28:指派对象由实例改为部署槽位(AssignRunnerSlotAsync), /// 因为 instance_id 含进程ID与启动时间,指派挂上去每次重启都会丢。 /// 本守卫的意图不变——唯一性与事务边界归 store,控制器只转发; /// 另外 is_runner 已降级为遗留列,控制器连读都不该有。 /// [Fact] public void AssignRunner_ReusesPackageBStore() { var source = MonitorSource(); Assert.Contains("_etlInstances.AssignRunnerSlotAsync", source); Assert.DoesNotContain("SET is_runner", source); Assert.DoesNotContain("IsRunner = true", source); // 退回按实例指派就是把根因放回去 Assert.DoesNotContain("AssignRunnerAsync(input.InstanceId", source); } [Fact] public void ModuleCatches_RethrowAdminCancelBeforeMarkingFailed() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md"))) dir = dir.Parent; Assert.NotNull(dir); string[] files = [ Path.Combine(dir!.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Order", "S1MdpSyncTransformService.cs"), Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Production", "S2MdpSyncTransformService.cs"), Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Supply", "S3MdpSyncTransformService.cs"), Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "ProcurementExecution", "S4MdpSyncTransformService.cs"), Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "S5MdpSyncTransformService.cs"), Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6MdpSyncTransformService.cs"), Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "S7MdpSyncTransformService.cs"), ]; foreach (var file in files) { var source = File.ReadAllText(file); var failed = source.IndexOf("MarkTransformRunFailedAsync", StringComparison.Ordinal); var rethrow = source.IndexOf("ex is ModuleRebuildCancelledException", StringComparison.Ordinal); Assert.True(rethrow >= 0 && rethrow < failed, Path.GetFileName(file) + " 必须先把管理员取消抛回去,不能先写成 FAILED"); } } }