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