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");
}
}
}