using System.Security.Cryptography; using System.Text; using Admin.NET.Core.Update; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.Migration; /// /// 迁移脚本 hash 跨平台行尾兼容契约。 /// /// 核心不变量: /// LINE ENDING DIFFERENCE = tolerated /// SQL CONTENT DIFFERENCE = blocked /// /// 即:同一份 SQL 在 Windows(CRLF) 与 Linux(LF) 检出得到的 hash 必须互相认可, /// 但只要 SQL 字符内容有任何变化(改 token、增删语句、改空格)就必须判定为不一致。 /// public class MigrationScriptHashTests { private const string SqlLf = "CREATE TABLE A (\nid BIGINT NOT NULL\n);\n"; private static byte[] Lf(string s) => Encoding.UTF8.GetBytes(s.Replace("\r\n", "\n")); private static byte[] Crlf(string s) => Encoding.UTF8.GetBytes(s.Replace("\r\n", "\n").Replace("\n", "\r\n")); private static string RawSha(byte[] b) => Convert.ToHexString(SHA256.HashData(b)); // TEST 1 — 文件 LF,库里存的是该 LF 文件的原始 hash → 必须通过 [Fact] public void Test1_LfFile_StoredLfRawHash_Matches() { var file = Lf(SqlLf); Assert.True(MigrationScriptHash.Matches(RawSha(file), file)); } // TEST 2 — 文件 LF,库里存的是等价 CRLF 版本的 hash → 必须通过 [Fact] public void Test2_LfFile_StoredCrlfEquivalentHash_Matches() { var file = Lf(SqlLf); var storedFromWindows = RawSha(Crlf(SqlLf)); Assert.NotEqual(RawSha(file), storedFromWindows); // 前提:两者 hash 确实不同 Assert.True(MigrationScriptHash.Matches(storedFromWindows, file)); } // TEST 3 — 文件 CRLF,库里存的是等价 LF 版本的 hash → 必须通过 [Fact] public void Test3_CrlfFile_StoredLfEquivalentHash_Matches() { var file = Crlf(SqlLf); var storedFromLinux = RawSha(Lf(SqlLf)); Assert.NotEqual(RawSha(file), storedFromLinux); Assert.True(MigrationScriptHash.Matches(storedFromLinux, file)); } // TEST 4 — 文件 CRLF,库里存的就是该 CRLF 文件的 hash → 必须通过 [Fact] public void Test4_CrlfFile_StoredCrlfHash_Matches() { var file = Crlf(SqlLf); Assert.True(MigrationScriptHash.Matches(RawSha(file), file)); } // TEST 5 — SQL 被真实改写(CREATE TABLE A → B)→ 必须拒绝 [Fact] public void Test5_RealSqlMutation_Rejected() { var stored = RawSha(Lf(SqlLf)); var mutated = Lf(SqlLf.Replace("CREATE TABLE A", "CREATE TABLE B")); Assert.False(MigrationScriptHash.Matches(stored, mutated)); } // TEST 6 — 增删 SQL token → 必须拒绝 [Fact] public void Test6_TokenAddedOrRemoved_Rejected() { var stored = RawSha(Lf(SqlLf)); var added = Lf(SqlLf.Replace("id BIGINT NOT NULL", "id BIGINT NOT NULL, name VARCHAR(10)")); Assert.False(MigrationScriptHash.Matches(stored, added)); var removed = Lf(SqlLf.Replace(" NOT NULL", string.Empty)); Assert.False(MigrationScriptHash.Matches(stored, removed)); } // TEST 7 — 只改变换行(含孤立 CR 的 Mac 老式行尾)→ 必须通过 [Fact] public void Test7_OnlyLineEndingChanged_Matches() { var stored = RawSha(Lf(SqlLf)); Assert.True(MigrationScriptHash.Matches(stored, Crlf(SqlLf))); var crOnly = Encoding.UTF8.GetBytes(SqlLf.Replace("\r\n", "\n").Replace("\n", "\r")); Assert.True(MigrationScriptHash.Matches(stored, crOnly)); } // TEST 8 — 新 migration 持久化的 hash 必须等于 canonical LF hash,且与平台无关 [Fact] public void Test8_PersistedHashIsCanonicalLf_AndPlatformIndependent() { var lf = Lf(SqlLf); var crlf = Crlf(SqlLf); Assert.Equal(RawSha(lf), MigrationScriptHash.ComputeCanonical(lf)); // 同一份 SQL,无论从哪种行尾的检出执行,写库的 canonical hash 必须一致 Assert.Equal(MigrationScriptHash.ComputeCanonical(lf), MigrationScriptHash.ComputeCanonical(crlf)); } // 空白差异不属于行尾差异,必须继续拒绝(防止 Matches 被误当成 Trim/去空白) [Fact] public void WhitespaceOtherThanLineEndings_StillRejected() { var stored = RawSha(Lf(SqlLf)); var extraSpace = Lf(SqlLf.Replace("id BIGINT", "id BIGINT")); Assert.False(MigrationScriptHash.Matches(stored, extraSpace)); var trailingSpace = Lf(SqlLf.Replace("CREATE TABLE A (", "CREATE TABLE A ( ")); Assert.False(MigrationScriptHash.Matches(stored, trailingSpace)); } // 空/缺失 stored hash 不得被当成匹配 [Fact] public void EmptyStoredHash_NeverMatches() { var file = Lf(SqlLf); Assert.False(MigrationScriptHash.Matches(null, file)); Assert.False(MigrationScriptHash.Matches(string.Empty, file)); Assert.False(MigrationScriptHash.Matches(" ", file)); } // 大小写不敏感(库里历史记录可能是小写十六进制) [Fact] public void StoredHashCaseInsensitive() { var file = Lf(SqlLf); Assert.True(MigrationScriptHash.Matches(RawSha(file).ToLowerInvariant(), file)); } }