|
|
@@ -0,0 +1,192 @@
|
|
|
+using System.Security.Cryptography;
|
|
|
+using Admin.NET.Core.Update;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// 已知失败迁移「重跑前恢复」的策略契约。
|
|
|
+///
|
|
|
+/// <para>被守护的核心不变量只有一条:<b>这条破坏性通道必须极其难以被误触发。</b>
|
|
|
+/// 它会 TRUNCATE 一张真实的表且不可回滚,所以四道闸门里任意一道松掉都是事故:
|
|
|
+/// · 版本没登记 → 不能动;
|
|
|
+/// · 从未跑过 / 已经成功 → 不能动(尤其「已成功」,动了就是破坏别人正常的库);
|
|
|
+/// · 日志 hash 或磁盘 hash 与已发布的原始脚本对不上 → 必须显式拒绝,不能沉默放行。</para>
|
|
|
+///
|
|
|
+/// <para>策略是纯函数,因此这些用例不需要数据库;副作用(TRUNCATE / 抛异常)由
|
|
|
+/// AutoVersionUpdate 执行,其真实运行结果另由本地 runtime 验证覆盖。</para>
|
|
|
+/// </summary>
|
|
|
+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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>大小写与首尾空白不应改变裁决 —— 不同驱动回读 hash 的形态不一致。</summary>
|
|
|
+ [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 ⇒ 绝不触碰 ─────────────────────────────────────────────
|
|
|
+ /// <summary>
|
|
|
+ /// 这是整组测试里最重要的一条。已成功的环境说明该迁移在那里本来就跑对了,
|
|
|
+ /// 恢复通道一旦在这种环境上生效,就是把别人正常的库清空。
|
|
|
+ /// </summary>
|
|
|
+ [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 不是登记的那一个 ⇒ 显式拒绝 ────────────────────────
|
|
|
+ /// <summary>
|
|
|
+ /// 不能沉默跳过:沉默会让人以为「恢复没生效但也没坏事」,
|
|
|
+ /// 实际是我们对一个来历不明的 1.0.517 做了判断却没说。TRUNCATE 不可回滚,必须停机报错。
|
|
|
+ /// </summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>日志 hash 对得上,但磁盘脚本被改过 —— 同样拒绝。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>从未跑过(无日志行)= 正常首次执行,不是抢救场景。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 幂等 ──────────────────────────────────────────────────────────────────────
|
|
|
+ /// <summary>
|
|
|
+ /// 恢复不写迁移日志,所以「TRUNCATE 完成后进程崩溃」时下次启动的输入完全相同,
|
|
|
+ /// 裁决必须仍然是 Truncate(对空表再清一次,无副作用),而不是变成 Reject 或 None。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void Decision_IsIdempotent_AcrossRepeatedStartups()
|
|
|
+ {
|
|
|
+ for (var i = 0; i < 3; i++)
|
|
|
+ Assert.Equal(MigrationRecoveryDecision.Truncate,
|
|
|
+ MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, true).Decision);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 登记表与真实脚本必须一致 ──────────────────────────────────────────────────
|
|
|
+ /// <summary>
|
|
|
+ /// 登记的 hash 必须等于仓库里那份 1.0.517.sql 的真实 SHA256。
|
|
|
+ ///
|
|
|
+ /// <para>这条防的是最隐蔽的一种走样:有人改了已发布的 1.0.517.sql 却忘了同步登记表。
|
|
|
+ /// 那时闸门 4 会对所有环境判 Reject,恢复通道静默失效,而故障现场看起来只是「又起不来」。</para>
|
|
|
+ /// </summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>登记表是破坏性授权清单:新增一条等于授权一次不可回滚操作,必须显式评审。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+}
|