|
|
@@ -0,0 +1,202 @@
|
|
|
+using Admin.NET.Core.Update;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// 已登记「迁移记录被误记」重整策略的契约。
|
|
|
+///
|
|
|
+/// <para>被守护的核心不变量有两条,方向相反、同等重要:</para>
|
|
|
+///
|
|
|
+/// <para><b>① 不能误伤正常环境。</b>闸门 1–4 任一不成立必须返回 None——
|
|
|
+/// 全新环境(无日志行)、已升级完成的环境(hash 已是目标值)、失败重试中的环境(非 Success),
|
|
|
+/// 这三种都是正常状态,一旦在这里 Reject 就是把好好的库打死。
|
|
|
+/// 其中「已升级完成」最危险:它与事故环境只差 file_hash 一个字段。</para>
|
|
|
+///
|
|
|
+/// <para><b>② 命中事故签名之后不能沉默放行。</b>闸门 5–7 是安全前提,
|
|
|
+/// 任一不成立必须 Reject 停机,绝不允许降级成「跳过重整继续启动」——
|
|
|
+/// 该通道会修改迁移历史,沉默放行等于在无人知晓的情况下改写执行史。</para>
|
|
|
+///
|
|
|
+/// <para>策略是纯函数,故这些用例不需要数据库;副作用(UPDATE / 抛异常)由
|
|
|
+/// AutoVersionUpdate 执行,其真实运行结果由隔离库的 First Run / Second Run 覆盖。</para>
|
|
|
+/// </summary>
|
|
|
+public class MigrationCollisionPolicyTests
|
|
|
+{
|
|
|
+ private const string V = "1.0.536";
|
|
|
+ private const string CollidedHash = "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61";
|
|
|
+ private const string TargetHash = "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776";
|
|
|
+ private const string DisplacedV = "1.0.539";
|
|
|
+ private const string DisplacedHash = "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42";
|
|
|
+
|
|
|
+ /// <summary>事故现场的完整参数;各用例只改动自己要验证的那一个维度。</summary>
|
|
|
+ private static MigrationCollisionOutcome Decide(
|
|
|
+ string version = V,
|
|
|
+ int matchedRowCount = 1,
|
|
|
+ string? loggedStatus = "Success",
|
|
|
+ string? loggedFileHash = CollidedHash,
|
|
|
+ bool targetDiskHashMatches = true,
|
|
|
+ string? displacedLoggedStatus = "Success",
|
|
|
+ string? displacedLoggedFileHash = DisplacedHash,
|
|
|
+ bool displacedDiskHashMatches = true)
|
|
|
+ => MigrationCollisionPolicy.Decide(
|
|
|
+ version, matchedRowCount, loggedStatus, loggedFileHash, targetDiskHashMatches,
|
|
|
+ displacedLoggedStatus, displacedLoggedFileHash, displacedDiskHashMatches);
|
|
|
+
|
|
|
+ // ── 正向:只有事故现场本身才允许重整 ────────────────────────────────────────────
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void ExactAccidentSignature_ResetsRecord()
|
|
|
+ {
|
|
|
+ var outcome = Decide();
|
|
|
+
|
|
|
+ Assert.Equal(MigrationCollisionDecision.ResetCollidedRecord, outcome.Decision);
|
|
|
+ Assert.Equal("COLLISION_STATUS_RESET", outcome.Plan!.Strategy);
|
|
|
+ Assert.Equal(DisplacedV, outcome.Plan.DisplacedVersion);
|
|
|
+ Assert.Null(outcome.RejectReason);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>不同驱动回读 hash / status 的形态不一致,大小写与首尾空白不应改变裁决。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("Success", "970e5a8f0133944cb04ac95e67112a2ec6e2e45f06ae61c3f2049163f7dc9a61")]
|
|
|
+ [InlineData("SUCCESS", " " + CollidedHash + " ")]
|
|
|
+ [InlineData("success", CollidedHash)]
|
|
|
+ public void CaseInsensitiveAndTrimmed_StillResets(string status, string hash)
|
|
|
+ => Assert.Equal(
|
|
|
+ MigrationCollisionDecision.ResetCollidedRecord,
|
|
|
+ Decide(loggedStatus: status, loggedFileHash: hash).Decision);
|
|
|
+
|
|
|
+ // ── 闸门 1–4:正常环境一律 None,绝不 Reject ──────────────────────────────────
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void UnregisteredVersion_IsNone()
|
|
|
+ {
|
|
|
+ var outcome = Decide(version: "1.0.551");
|
|
|
+
|
|
|
+ Assert.Equal(MigrationCollisionDecision.None, outcome.Decision);
|
|
|
+ Assert.Null(outcome.Plan);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>全新环境:该版本从未跑过 ⇒ 正常首次执行,不是事故场景。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void FreshEnvironmentWithNoRow_IsNone()
|
|
|
+ => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 0, loggedStatus: null, loggedFileHash: null).Decision);
|
|
|
+
|
|
|
+ /// <summary>唯一键理论上保证不会有多行;真出现了也只能停手,不能猜该动哪一行。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void MultipleRowsForVersion_IsNone()
|
|
|
+ => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 2).Decision);
|
|
|
+
|
|
|
+ [Theory]
|
|
|
+ [InlineData("Failed")]
|
|
|
+ [InlineData("Running")]
|
|
|
+ [InlineData("Skipped")]
|
|
|
+ [InlineData("Superseded")]
|
|
|
+ public void NonSuccessStatus_IsNone(string status)
|
|
|
+ => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedStatus: status).Decision);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 最危险的一条:<b>已经升级完成</b>的环境,记录里是目标 hash + Success。
|
|
|
+ /// 它与事故环境只差 file_hash 一个字段,若这里 Reject 就会把所有正常环境打死,
|
|
|
+ /// 必须放行给 ShouldSkipScript 按常规「hash 未变 ⇒ 跳过」处理。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void AlreadyUpgradedEnvironment_IsNone_NotReject()
|
|
|
+ {
|
|
|
+ var outcome = Decide(loggedFileHash: TargetHash);
|
|
|
+
|
|
|
+ Assert.Equal(MigrationCollisionDecision.None, outcome.Decision);
|
|
|
+ Assert.Null(outcome.RejectReason);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>某个我们没见过的 1.0.536 ⇒ 不认领,交给既有 hash 守卫去拒绝启动。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void UnknownHashForRegisteredVersion_IsNone()
|
|
|
+ => Assert.Equal(
|
|
|
+ MigrationCollisionDecision.None,
|
|
|
+ Decide(loggedFileHash: "0000000000000000000000000000000000000000000000000000000000000000").Decision);
|
|
|
+
|
|
|
+ // ── 闸门 5–7:命中签名后,安全前提不成立一律 Reject ────────────────────────────
|
|
|
+
|
|
|
+ /// <summary>命中事故签名但磁盘不是目标 revision ⇒ 重整后会跑出未预期的脚本,必须停机。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void SignatureMatchedButWrongRevision_Rejects()
|
|
|
+ {
|
|
|
+ var outcome = Decide(targetDiskHashMatches: false);
|
|
|
+
|
|
|
+ Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision);
|
|
|
+ Assert.Contains(TargetHash, outcome.RejectReason);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 最关键的一道:被顶替的内容必须已被合法记录在案。
|
|
|
+ /// 拿不出这个证据就重整,等于可能抹掉一次真实发生过的执行史。
|
|
|
+ /// </summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData(null, DisplacedHash)]
|
|
|
+ [InlineData("Failed", DisplacedHash)]
|
|
|
+ [InlineData("Success", null)]
|
|
|
+ [InlineData("Success", "0000000000000000000000000000000000000000000000000000000000000000")]
|
|
|
+ public void DisplacedContentNotProvenRecorded_Rejects(string? status, string? hash)
|
|
|
+ {
|
|
|
+ var outcome = Decide(displacedLoggedStatus: status, displacedLoggedFileHash: hash);
|
|
|
+
|
|
|
+ Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision);
|
|
|
+ Assert.Contains(DisplacedV, outcome.RejectReason);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>磁盘上的被顶替脚本也必须仍是登记的那一份,否则「已记录的」与「本 revision 携带的」不是同一个东西。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void DisplacedScriptOnDiskDiffers_Rejects()
|
|
|
+ => Assert.Equal(MigrationCollisionDecision.Reject, Decide(displacedDiskHashMatches: false).Decision);
|
|
|
+
|
|
|
+ // ── 幂等:三种中断/完成状态下的收敛行为 ──────────────────────────────────────
|
|
|
+
|
|
|
+ /// <summary>First Run 成功后:hash 已是目标值 ⇒ 永不再触发。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void AfterSuccessfulFirstRun_NeverTriggersAgain()
|
|
|
+ => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedFileHash: TargetHash).Decision);
|
|
|
+
|
|
|
+ /// <summary>重整后崩溃(Superseded + 旧 hash):闸门 3 不成立 ⇒ None;
|
|
|
+ /// 而 ShouldSkipScript 对非 Success 行返回 false,脚本照常执行,仍然收敛。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void AfterResetButBeforeExecution_IsNone_SoNormalFlowProceeds()
|
|
|
+ => Assert.Equal(
|
|
|
+ MigrationCollisionDecision.None,
|
|
|
+ Decide(loggedStatus: MigrationCollisionPolicy.SupersededStatus).Decision);
|
|
|
+
|
|
|
+ /// <summary>正式脚本执行失败(目标 hash + Failed):走常规重试,不再经过本策略。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void AfterFailedExecution_IsNone()
|
|
|
+ => Assert.Equal(
|
|
|
+ MigrationCollisionDecision.None,
|
|
|
+ Decide(loggedStatus: "Failed", loggedFileHash: TargetHash).Decision);
|
|
|
+
|
|
|
+ // ── 登记表本身的约束 ────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 登记表只应包含这一条已评审的事故。新增条目 = 授权一次对迁移历史的修改,
|
|
|
+ /// 必须先更新本用例,以强制评审发生。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void RegistryContainsOnlyTheReviewedCollision()
|
|
|
+ {
|
|
|
+ var plan = Assert.Single(MigrationCollisionPolicy.KnownCollisions);
|
|
|
+
|
|
|
+ Assert.Equal(V, plan.Version);
|
|
|
+ Assert.Equal(CollidedHash, plan.CollidedFileHash);
|
|
|
+ Assert.Equal(TargetHash, plan.ExpectedDiskHash);
|
|
|
+ Assert.Equal(DisplacedV, plan.DisplacedVersion);
|
|
|
+ Assert.Equal(DisplacedHash, plan.DisplacedFileHash);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 重整写入的中间状态必须避开全部既有状态值,否则会被正常流程误解
|
|
|
+ /// (例如被 successVersions 统计、或被失败恢复策略认领)。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void SupersededStatusDoesNotCollideWithExistingStatuses()
|
|
|
+ {
|
|
|
+ foreach (var existing in new[] { "Running", "Success", "Failed", "Skipped" })
|
|
|
+ Assert.NotEqual(existing, MigrationCollisionPolicy.SupersededStatus, StringComparer.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+}
|