MigrationCollisionPolicyTests.cs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. using Admin.NET.Core.Update;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
  4. /// <summary>
  5. /// 已登记「迁移记录被误记」重整策略的契约。
  6. ///
  7. /// <para>被守护的核心不变量有两条,方向相反、同等重要:</para>
  8. ///
  9. /// <para><b>① 不能误伤正常环境。</b>闸门 1–4 任一不成立必须返回 None——
  10. /// 全新环境(无日志行)、已升级完成的环境(hash 已是目标值)、失败重试中的环境(非 Success),
  11. /// 这三种都是正常状态,一旦在这里 Reject 就是把好好的库打死。
  12. /// 其中「已升级完成」最危险:它与事故环境只差 file_hash 一个字段。</para>
  13. ///
  14. /// <para><b>② 命中事故签名之后不能沉默放行。</b>闸门 5–7 是安全前提,
  15. /// 任一不成立必须 Reject 停机,绝不允许降级成「跳过重整继续启动」——
  16. /// 该通道会修改迁移历史,沉默放行等于在无人知晓的情况下改写执行史。</para>
  17. ///
  18. /// <para>策略是纯函数,故这些用例不需要数据库;副作用(UPDATE / 抛异常)由
  19. /// AutoVersionUpdate 执行,其真实运行结果由隔离库的 First Run / Second Run 覆盖。</para>
  20. /// </summary>
  21. public class MigrationCollisionPolicyTests
  22. {
  23. private const string V = "1.0.536";
  24. private const string CollidedHash = "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61";
  25. private const string TargetHash = "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776";
  26. private const string DisplacedV = "1.0.539";
  27. private const string DisplacedHash = "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42";
  28. /// <summary>事故现场的完整参数;各用例只改动自己要验证的那一个维度。</summary>
  29. private static MigrationCollisionOutcome Decide(
  30. string version = V,
  31. int matchedRowCount = 1,
  32. string? loggedStatus = "Success",
  33. string? loggedFileHash = CollidedHash,
  34. bool targetDiskHashMatches = true,
  35. string? displacedLoggedStatus = "Success",
  36. string? displacedLoggedFileHash = DisplacedHash,
  37. bool displacedDiskHashMatches = true)
  38. => MigrationCollisionPolicy.Decide(
  39. version, matchedRowCount, loggedStatus, loggedFileHash, targetDiskHashMatches,
  40. displacedLoggedStatus, displacedLoggedFileHash, displacedDiskHashMatches);
  41. // ── 正向:只有事故现场本身才允许重整 ────────────────────────────────────────────
  42. [Fact]
  43. public void ExactAccidentSignature_ResetsRecord()
  44. {
  45. var outcome = Decide();
  46. Assert.Equal(MigrationCollisionDecision.ResetCollidedRecord, outcome.Decision);
  47. Assert.Equal("COLLISION_STATUS_RESET", outcome.Plan!.Strategy);
  48. Assert.Equal(DisplacedV, outcome.Plan.DisplacedVersion);
  49. Assert.Null(outcome.RejectReason);
  50. }
  51. /// <summary>不同驱动回读 hash / status 的形态不一致,大小写与首尾空白不应改变裁决。</summary>
  52. [Theory]
  53. [InlineData("Success", "970e5a8f0133944cb04ac95e67112a2ec6e2e45f06ae61c3f2049163f7dc9a61")]
  54. [InlineData("SUCCESS", " " + CollidedHash + " ")]
  55. [InlineData("success", CollidedHash)]
  56. public void CaseInsensitiveAndTrimmed_StillResets(string status, string hash)
  57. => Assert.Equal(
  58. MigrationCollisionDecision.ResetCollidedRecord,
  59. Decide(loggedStatus: status, loggedFileHash: hash).Decision);
  60. // ── 闸门 1–4:正常环境一律 None,绝不 Reject ──────────────────────────────────
  61. [Fact]
  62. public void UnregisteredVersion_IsNone()
  63. {
  64. var outcome = Decide(version: "1.0.551");
  65. Assert.Equal(MigrationCollisionDecision.None, outcome.Decision);
  66. Assert.Null(outcome.Plan);
  67. }
  68. /// <summary>全新环境:该版本从未跑过 ⇒ 正常首次执行,不是事故场景。</summary>
  69. [Fact]
  70. public void FreshEnvironmentWithNoRow_IsNone()
  71. => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 0, loggedStatus: null, loggedFileHash: null).Decision);
  72. /// <summary>唯一键理论上保证不会有多行;真出现了也只能停手,不能猜该动哪一行。</summary>
  73. [Fact]
  74. public void MultipleRowsForVersion_IsNone()
  75. => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 2).Decision);
  76. [Theory]
  77. [InlineData("Failed")]
  78. [InlineData("Running")]
  79. [InlineData("Skipped")]
  80. [InlineData("Superseded")]
  81. public void NonSuccessStatus_IsNone(string status)
  82. => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedStatus: status).Decision);
  83. /// <summary>
  84. /// 最危险的一条:<b>已经升级完成</b>的环境,记录里是目标 hash + Success。
  85. /// 它与事故环境只差 file_hash 一个字段,若这里 Reject 就会把所有正常环境打死,
  86. /// 必须放行给 ShouldSkipScript 按常规「hash 未变 ⇒ 跳过」处理。
  87. /// </summary>
  88. [Fact]
  89. public void AlreadyUpgradedEnvironment_IsNone_NotReject()
  90. {
  91. var outcome = Decide(loggedFileHash: TargetHash);
  92. Assert.Equal(MigrationCollisionDecision.None, outcome.Decision);
  93. Assert.Null(outcome.RejectReason);
  94. }
  95. /// <summary>某个我们没见过的 1.0.536 ⇒ 不认领,交给既有 hash 守卫去拒绝启动。</summary>
  96. [Fact]
  97. public void UnknownHashForRegisteredVersion_IsNone()
  98. => Assert.Equal(
  99. MigrationCollisionDecision.None,
  100. Decide(loggedFileHash: "0000000000000000000000000000000000000000000000000000000000000000").Decision);
  101. // ── 闸门 5–7:命中签名后,安全前提不成立一律 Reject ────────────────────────────
  102. /// <summary>命中事故签名但磁盘不是目标 revision ⇒ 重整后会跑出未预期的脚本,必须停机。</summary>
  103. [Fact]
  104. public void SignatureMatchedButWrongRevision_Rejects()
  105. {
  106. var outcome = Decide(targetDiskHashMatches: false);
  107. Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision);
  108. Assert.Contains(TargetHash, outcome.RejectReason);
  109. }
  110. /// <summary>
  111. /// 最关键的一道:被顶替的内容必须已被合法记录在案。
  112. /// 拿不出这个证据就重整,等于可能抹掉一次真实发生过的执行史。
  113. /// </summary>
  114. [Theory]
  115. [InlineData(null, DisplacedHash)]
  116. [InlineData("Failed", DisplacedHash)]
  117. [InlineData("Success", null)]
  118. [InlineData("Success", "0000000000000000000000000000000000000000000000000000000000000000")]
  119. public void DisplacedContentNotProvenRecorded_Rejects(string? status, string? hash)
  120. {
  121. var outcome = Decide(displacedLoggedStatus: status, displacedLoggedFileHash: hash);
  122. Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision);
  123. Assert.Contains(DisplacedV, outcome.RejectReason);
  124. }
  125. /// <summary>磁盘上的被顶替脚本也必须仍是登记的那一份,否则「已记录的」与「本 revision 携带的」不是同一个东西。</summary>
  126. [Fact]
  127. public void DisplacedScriptOnDiskDiffers_Rejects()
  128. => Assert.Equal(MigrationCollisionDecision.Reject, Decide(displacedDiskHashMatches: false).Decision);
  129. // ── 幂等:三种中断/完成状态下的收敛行为 ──────────────────────────────────────
  130. /// <summary>First Run 成功后:hash 已是目标值 ⇒ 永不再触发。</summary>
  131. [Fact]
  132. public void AfterSuccessfulFirstRun_NeverTriggersAgain()
  133. => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedFileHash: TargetHash).Decision);
  134. /// <summary>重整后崩溃(Superseded + 旧 hash):闸门 3 不成立 ⇒ None;
  135. /// 而 ShouldSkipScript 对非 Success 行返回 false,脚本照常执行,仍然收敛。</summary>
  136. [Fact]
  137. public void AfterResetButBeforeExecution_IsNone_SoNormalFlowProceeds()
  138. => Assert.Equal(
  139. MigrationCollisionDecision.None,
  140. Decide(loggedStatus: MigrationCollisionPolicy.SupersededStatus).Decision);
  141. /// <summary>正式脚本执行失败(目标 hash + Failed):走常规重试,不再经过本策略。</summary>
  142. [Fact]
  143. public void AfterFailedExecution_IsNone()
  144. => Assert.Equal(
  145. MigrationCollisionDecision.None,
  146. Decide(loggedStatus: "Failed", loggedFileHash: TargetHash).Decision);
  147. // ── 登记表本身的约束 ────────────────────────────────────────────────────────
  148. /// <summary>
  149. /// 登记表只应包含这一条已评审的事故。新增条目 = 授权一次对迁移历史的修改,
  150. /// 必须先更新本用例,以强制评审发生。
  151. /// </summary>
  152. [Fact]
  153. public void RegistryContainsOnlyTheReviewedCollision()
  154. {
  155. var plan = Assert.Single(MigrationCollisionPolicy.KnownCollisions);
  156. Assert.Equal(V, plan.Version);
  157. Assert.Equal(CollidedHash, plan.CollidedFileHash);
  158. Assert.Equal(TargetHash, plan.ExpectedDiskHash);
  159. Assert.Equal(DisplacedV, plan.DisplacedVersion);
  160. Assert.Equal(DisplacedHash, plan.DisplacedFileHash);
  161. }
  162. /// <summary>
  163. /// 重整写入的中间状态必须避开全部既有状态值,否则会被正常流程误解
  164. /// (例如被 successVersions 统计、或被失败恢复策略认领)。
  165. /// </summary>
  166. [Fact]
  167. public void SupersededStatusDoesNotCollideWithExistingStatuses()
  168. {
  169. foreach (var existing in new[] { "Running", "Success", "Failed", "Skipped" })
  170. Assert.NotEqual(existing, MigrationCollisionPolicy.SupersededStatus, StringComparer.OrdinalIgnoreCase);
  171. }
  172. }