MigrationRecoveryPolicyTests.cs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. using System.Security.Cryptography;
  2. using Admin.NET.Core.Update;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
  5. /// <summary>
  6. /// 已知失败迁移「重跑前恢复」的策略契约。
  7. ///
  8. /// <para>被守护的核心不变量只有一条:<b>这条破坏性通道必须极其难以被误触发。</b>
  9. /// 它会 TRUNCATE 一张真实的表且不可回滚,所以四道闸门里任意一道松掉都是事故:
  10. /// · 版本没登记 → 不能动;
  11. /// · 从未跑过 / 已经成功 → 不能动(尤其「已成功」,动了就是破坏别人正常的库);
  12. /// · 日志 hash 或磁盘 hash 与已发布的原始脚本对不上 → 必须显式拒绝,不能沉默放行。</para>
  13. ///
  14. /// <para>策略是纯函数,因此这些用例不需要数据库;副作用(TRUNCATE / 抛异常)由
  15. /// AutoVersionUpdate 执行,其真实运行结果另由本地 runtime 验证覆盖。</para>
  16. /// </summary>
  17. public class MigrationRecoveryPolicyTests
  18. {
  19. private const string V = "1.0.517";
  20. private const string OriginalHash = "EF2722954B4BB5BEA465F63E764C927F01E9E52109738478E89CE94145ECB50B";
  21. // ── CASE A:Failed + 原始 hash ⇒ 允许恢复 ──────────────────────────────────────
  22. [Fact]
  23. public void CaseA_FailedWithOriginalHash_Truncates()
  24. {
  25. var outcome = MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, diskHashMatchesOriginal: true);
  26. Assert.Equal(MigrationRecoveryDecision.Truncate, outcome.Decision);
  27. Assert.Equal("dwd_requirement_examine_detail", outcome.Plan!.TableName);
  28. Assert.Equal("DWD_TRUNCATE_PRE_RETRY", outcome.Plan.Strategy);
  29. Assert.Null(outcome.RejectReason);
  30. }
  31. /// <summary>大小写与首尾空白不应改变裁决 —— 不同驱动回读 hash 的形态不一致。</summary>
  32. [Theory]
  33. [InlineData("Failed")]
  34. [InlineData("failed")]
  35. [InlineData("FAILED")]
  36. public void CaseA_StatusComparisonIsCaseInsensitive(string status)
  37. {
  38. Assert.Equal(MigrationRecoveryDecision.Truncate,
  39. MigrationRecoveryPolicy.Decide(V, status, " " + OriginalHash.ToLowerInvariant() + " ", true).Decision);
  40. }
  41. // ── CASE B:已 Success ⇒ 绝不触碰 ─────────────────────────────────────────────
  42. /// <summary>
  43. /// 这是整组测试里最重要的一条。已成功的环境说明该迁移在那里本来就跑对了,
  44. /// 恢复通道一旦在这种环境上生效,就是把别人正常的库清空。
  45. /// </summary>
  46. [Fact]
  47. public void CaseB_AlreadySucceeded_IsNeverTouched()
  48. {
  49. var outcome = MigrationRecoveryPolicy.Decide(V, "Success", OriginalHash, diskHashMatchesOriginal: true);
  50. Assert.Equal(MigrationRecoveryDecision.None, outcome.Decision);
  51. Assert.Null(outcome.RejectReason);
  52. }
  53. [Theory]
  54. [InlineData("Success")]
  55. [InlineData("Running")]
  56. [InlineData("Skipped")]
  57. public void CaseB_AnyNonFailedStatus_IsNeverTouched(string status)
  58. {
  59. Assert.Equal(MigrationRecoveryDecision.None,
  60. MigrationRecoveryPolicy.Decide(V, status, OriginalHash, true).Decision);
  61. }
  62. // ── CASE C:Failed 但 hash 不是登记的那一个 ⇒ 显式拒绝 ────────────────────────
  63. /// <summary>
  64. /// 不能沉默跳过:沉默会让人以为「恢复没生效但也没坏事」,
  65. /// 实际是我们对一个来历不明的 1.0.517 做了判断却没说。TRUNCATE 不可回滚,必须停机报错。
  66. /// </summary>
  67. [Fact]
  68. public void CaseC_FailedWithUnknownLoggedHash_IsRejected_NotSilentlySkipped()
  69. {
  70. var outcome = MigrationRecoveryPolicy.Decide(
  71. V, "Failed", "0000000000000000000000000000000000000000000000000000000000000000", true);
  72. Assert.Equal(MigrationRecoveryDecision.Reject, outcome.Decision);
  73. Assert.Contains("SHA256", outcome.RejectReason!, StringComparison.Ordinal);
  74. }
  75. /// <summary>日志 hash 对得上,但磁盘脚本被改过 —— 同样拒绝。</summary>
  76. [Fact]
  77. public void CaseC_FailedButDiskScriptModified_IsRejected()
  78. {
  79. var outcome = MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, diskHashMatchesOriginal: false);
  80. Assert.Equal(MigrationRecoveryDecision.Reject, outcome.Decision);
  81. Assert.NotNull(outcome.RejectReason);
  82. }
  83. [Fact]
  84. public void CaseC_FailedWithNullOrEmptyLoggedHash_IsRejected()
  85. {
  86. Assert.Equal(MigrationRecoveryDecision.Reject, MigrationRecoveryPolicy.Decide(V, "Failed", null, true).Decision);
  87. Assert.Equal(MigrationRecoveryDecision.Reject, MigrationRecoveryPolicy.Decide(V, "Failed", "", true).Decision);
  88. }
  89. // ── 未登记版本 / 从未跑过 ⇒ 什么都不做 ────────────────────────────────────────
  90. [Theory]
  91. [InlineData("1.0.516")]
  92. [InlineData("1.0.518")]
  93. [InlineData("1.0.510")]
  94. public void UnregisteredVersion_IsNeverTouched_EvenWhenFailed(string version)
  95. {
  96. var outcome = MigrationRecoveryPolicy.Decide(version, "Failed", OriginalHash, true);
  97. Assert.Equal(MigrationRecoveryDecision.None, outcome.Decision);
  98. Assert.Null(outcome.Plan);
  99. }
  100. /// <summary>从未跑过(无日志行)= 正常首次执行,不是抢救场景。</summary>
  101. [Fact]
  102. public void NeverRun_IsNeverTouched()
  103. {
  104. Assert.Equal(MigrationRecoveryDecision.None, MigrationRecoveryPolicy.Decide(V, null, null, true).Decision);
  105. Assert.Equal(MigrationRecoveryDecision.None, MigrationRecoveryPolicy.Decide(V, " ", null, true).Decision);
  106. }
  107. // ── 幂等 ──────────────────────────────────────────────────────────────────────
  108. /// <summary>
  109. /// 恢复不写迁移日志,所以「TRUNCATE 完成后进程崩溃」时下次启动的输入完全相同,
  110. /// 裁决必须仍然是 Truncate(对空表再清一次,无副作用),而不是变成 Reject 或 None。
  111. /// </summary>
  112. [Fact]
  113. public void Decision_IsIdempotent_AcrossRepeatedStartups()
  114. {
  115. for (var i = 0; i < 3; i++)
  116. Assert.Equal(MigrationRecoveryDecision.Truncate,
  117. MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, true).Decision);
  118. }
  119. // ── 登记表与真实脚本必须一致 ──────────────────────────────────────────────────
  120. /// <summary>
  121. /// 登记的 hash 必须等于仓库里那份 1.0.517.sql 的真实 SHA256。
  122. ///
  123. /// <para>这条防的是最隐蔽的一种走样:有人改了已发布的 1.0.517.sql 却忘了同步登记表。
  124. /// 那时闸门 4 会对所有环境判 Reject,恢复通道静默失效,而故障现场看起来只是「又起不来」。</para>
  125. /// </summary>
  126. [Fact]
  127. public void RegisteredHash_MatchesTheActualPublishedScript()
  128. {
  129. var plan = Assert.Single(MigrationRecoveryPolicy.KnownFailedRecoveries, x => x.Version == V);
  130. var path = FindScript($"{V}.sql");
  131. var actual = Convert.ToHexString(SHA256.HashData(NormalizeToLf(File.ReadAllBytes(path))));
  132. Assert.Equal(plan.OriginalFileHash, actual, ignoreCase: true);
  133. }
  134. /// <summary>登记表是破坏性授权清单:新增一条等于授权一次不可回滚操作,必须显式评审。</summary>
  135. [Fact]
  136. public void Registry_ContainsOnlyTheDeliberatelyReviewedEntries()
  137. {
  138. Assert.Single(MigrationRecoveryPolicy.KnownFailedRecoveries);
  139. Assert.All(MigrationRecoveryPolicy.KnownFailedRecoveries,
  140. x => Assert.Equal(64, x.OriginalFileHash.Length));
  141. }
  142. private static byte[] NormalizeToLf(byte[] content)
  143. {
  144. var output = new byte[content.Length];
  145. var length = 0;
  146. for (var i = 0; i < content.Length; i++)
  147. {
  148. if (content[i] == 0x0D)
  149. {
  150. if (i + 1 < content.Length && content[i + 1] == 0x0A) i++;
  151. output[length++] = 0x0A;
  152. continue;
  153. }
  154. output[length++] = content[i];
  155. }
  156. return output.AsSpan(0, length).ToArray();
  157. }
  158. private static string FindScript(string fileName)
  159. {
  160. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  161. while (dir != null)
  162. {
  163. var candidate = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts", fileName);
  164. if (File.Exists(candidate)) return candidate;
  165. dir = dir.Parent;
  166. }
  167. throw new FileNotFoundException(fileName);
  168. }
  169. }