using System.Security.Cryptography;
using Admin.NET.Core.Update;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
///
/// 已知失败迁移「重跑前恢复」的策略契约。
///
/// 被守护的核心不变量只有一条:这条破坏性通道必须极其难以被误触发。
/// 它会 TRUNCATE 一张真实的表且不可回滚,所以四道闸门里任意一道松掉都是事故:
/// · 版本没登记 → 不能动;
/// · 从未跑过 / 已经成功 → 不能动(尤其「已成功」,动了就是破坏别人正常的库);
/// · 日志 hash 或磁盘 hash 与已发布的原始脚本对不上 → 必须显式拒绝,不能沉默放行。
///
/// 策略是纯函数,因此这些用例不需要数据库;副作用(TRUNCATE / 抛异常)由
/// AutoVersionUpdate 执行,其真实运行结果另由本地 runtime 验证覆盖。
///
public class MigrationRecoveryPolicyTests
{
private const string V = "1.0.517";
private const string OriginalHash = "EF2722954B4BB5BEA465F63E764C927F01E9E52109738478E89CE94145ECB50B";
// ── CASE A:Failed + 原始 hash ⇒ 允许恢复 ──────────────────────────────────────
[Fact]
public void CaseA_FailedWithOriginalHash_Truncates()
{
var outcome = MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, diskHashMatchesOriginal: true);
Assert.Equal(MigrationRecoveryDecision.Truncate, outcome.Decision);
Assert.Equal("dwd_requirement_examine_detail", outcome.Plan!.TableName);
Assert.Equal("DWD_TRUNCATE_PRE_RETRY", outcome.Plan.Strategy);
Assert.Null(outcome.RejectReason);
}
/// 大小写与首尾空白不应改变裁决 —— 不同驱动回读 hash 的形态不一致。
[Theory]
[InlineData("Failed")]
[InlineData("failed")]
[InlineData("FAILED")]
public void CaseA_StatusComparisonIsCaseInsensitive(string status)
{
Assert.Equal(MigrationRecoveryDecision.Truncate,
MigrationRecoveryPolicy.Decide(V, status, " " + OriginalHash.ToLowerInvariant() + " ", true).Decision);
}
// ── CASE B:已 Success ⇒ 绝不触碰 ─────────────────────────────────────────────
///
/// 这是整组测试里最重要的一条。已成功的环境说明该迁移在那里本来就跑对了,
/// 恢复通道一旦在这种环境上生效,就是把别人正常的库清空。
///
[Fact]
public void CaseB_AlreadySucceeded_IsNeverTouched()
{
var outcome = MigrationRecoveryPolicy.Decide(V, "Success", OriginalHash, diskHashMatchesOriginal: true);
Assert.Equal(MigrationRecoveryDecision.None, outcome.Decision);
Assert.Null(outcome.RejectReason);
}
[Theory]
[InlineData("Success")]
[InlineData("Running")]
[InlineData("Skipped")]
public void CaseB_AnyNonFailedStatus_IsNeverTouched(string status)
{
Assert.Equal(MigrationRecoveryDecision.None,
MigrationRecoveryPolicy.Decide(V, status, OriginalHash, true).Decision);
}
// ── CASE C:Failed 但 hash 不是登记的那一个 ⇒ 显式拒绝 ────────────────────────
///
/// 不能沉默跳过:沉默会让人以为「恢复没生效但也没坏事」,
/// 实际是我们对一个来历不明的 1.0.517 做了判断却没说。TRUNCATE 不可回滚,必须停机报错。
///
[Fact]
public void CaseC_FailedWithUnknownLoggedHash_IsRejected_NotSilentlySkipped()
{
var outcome = MigrationRecoveryPolicy.Decide(
V, "Failed", "0000000000000000000000000000000000000000000000000000000000000000", true);
Assert.Equal(MigrationRecoveryDecision.Reject, outcome.Decision);
Assert.Contains("SHA256", outcome.RejectReason!, StringComparison.Ordinal);
}
/// 日志 hash 对得上,但磁盘脚本被改过 —— 同样拒绝。
[Fact]
public void CaseC_FailedButDiskScriptModified_IsRejected()
{
var outcome = MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, diskHashMatchesOriginal: false);
Assert.Equal(MigrationRecoveryDecision.Reject, outcome.Decision);
Assert.NotNull(outcome.RejectReason);
}
[Fact]
public void CaseC_FailedWithNullOrEmptyLoggedHash_IsRejected()
{
Assert.Equal(MigrationRecoveryDecision.Reject, MigrationRecoveryPolicy.Decide(V, "Failed", null, true).Decision);
Assert.Equal(MigrationRecoveryDecision.Reject, MigrationRecoveryPolicy.Decide(V, "Failed", "", true).Decision);
}
// ── 未登记版本 / 从未跑过 ⇒ 什么都不做 ────────────────────────────────────────
[Theory]
[InlineData("1.0.516")]
[InlineData("1.0.518")]
[InlineData("1.0.510")]
public void UnregisteredVersion_IsNeverTouched_EvenWhenFailed(string version)
{
var outcome = MigrationRecoveryPolicy.Decide(version, "Failed", OriginalHash, true);
Assert.Equal(MigrationRecoveryDecision.None, outcome.Decision);
Assert.Null(outcome.Plan);
}
/// 从未跑过(无日志行)= 正常首次执行,不是抢救场景。
[Fact]
public void NeverRun_IsNeverTouched()
{
Assert.Equal(MigrationRecoveryDecision.None, MigrationRecoveryPolicy.Decide(V, null, null, true).Decision);
Assert.Equal(MigrationRecoveryDecision.None, MigrationRecoveryPolicy.Decide(V, " ", null, true).Decision);
}
// ── 幂等 ──────────────────────────────────────────────────────────────────────
///
/// 恢复不写迁移日志,所以「TRUNCATE 完成后进程崩溃」时下次启动的输入完全相同,
/// 裁决必须仍然是 Truncate(对空表再清一次,无副作用),而不是变成 Reject 或 None。
///
[Fact]
public void Decision_IsIdempotent_AcrossRepeatedStartups()
{
for (var i = 0; i < 3; i++)
Assert.Equal(MigrationRecoveryDecision.Truncate,
MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, true).Decision);
}
// ── 登记表与真实脚本必须一致 ──────────────────────────────────────────────────
///
/// 登记的 hash 必须等于仓库里那份 1.0.517.sql 的真实 SHA256。
///
/// 这条防的是最隐蔽的一种走样:有人改了已发布的 1.0.517.sql 却忘了同步登记表。
/// 那时闸门 4 会对所有环境判 Reject,恢复通道静默失效,而故障现场看起来只是「又起不来」。
///
[Fact]
public void RegisteredHash_MatchesTheActualPublishedScript()
{
var plan = Assert.Single(MigrationRecoveryPolicy.KnownFailedRecoveries, x => x.Version == V);
var path = FindScript($"{V}.sql");
var actual = Convert.ToHexString(SHA256.HashData(NormalizeToLf(File.ReadAllBytes(path))));
Assert.Equal(plan.OriginalFileHash, actual, ignoreCase: true);
}
/// 登记表是破坏性授权清单:新增一条等于授权一次不可回滚操作,必须显式评审。
[Fact]
public void Registry_ContainsOnlyTheDeliberatelyReviewedEntries()
{
Assert.Single(MigrationRecoveryPolicy.KnownFailedRecoveries);
Assert.All(MigrationRecoveryPolicy.KnownFailedRecoveries,
x => Assert.Equal(64, x.OriginalFileHash.Length));
}
private static byte[] NormalizeToLf(byte[] content)
{
var output = new byte[content.Length];
var length = 0;
for (var i = 0; i < content.Length; i++)
{
if (content[i] == 0x0D)
{
if (i + 1 < content.Length && content[i + 1] == 0x0A) i++;
output[length++] = 0x0A;
continue;
}
output[length++] = content[i];
}
return output.AsSpan(0, length).ToArray();
}
private static string FindScript(string fileName)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts", fileName);
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(fileName);
}
}