|
|
@@ -0,0 +1,144 @@
|
|
|
+using System.Security.Cryptography;
|
|
|
+using System.Text;
|
|
|
+using Admin.NET.Core.Update;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// 迁移脚本 hash 跨平台行尾兼容契约。
|
|
|
+///
|
|
|
+/// 核心不变量:
|
|
|
+/// LINE ENDING DIFFERENCE = tolerated
|
|
|
+/// SQL CONTENT DIFFERENCE = blocked
|
|
|
+///
|
|
|
+/// 即:同一份 SQL 在 Windows(CRLF) 与 Linux(LF) 检出得到的 hash 必须互相认可,
|
|
|
+/// 但只要 SQL 字符内容有任何变化(改 token、增删语句、改空格)就必须判定为不一致。
|
|
|
+/// </summary>
|
|
|
+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));
|
|
|
+ }
|
|
|
+}
|