MigrationScriptHashTests.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. using System.Security.Cryptography;
  2. using System.Text;
  3. using Admin.NET.Core.Update;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
  6. /// <summary>
  7. /// 迁移脚本 hash 跨平台行尾兼容契约。
  8. ///
  9. /// 核心不变量:
  10. /// LINE ENDING DIFFERENCE = tolerated
  11. /// SQL CONTENT DIFFERENCE = blocked
  12. ///
  13. /// 即:同一份 SQL 在 Windows(CRLF) 与 Linux(LF) 检出得到的 hash 必须互相认可,
  14. /// 但只要 SQL 字符内容有任何变化(改 token、增删语句、改空格)就必须判定为不一致。
  15. /// </summary>
  16. public class MigrationScriptHashTests
  17. {
  18. private const string SqlLf =
  19. "CREATE TABLE A (\nid BIGINT NOT NULL\n);\n";
  20. private static byte[] Lf(string s) => Encoding.UTF8.GetBytes(s.Replace("\r\n", "\n"));
  21. private static byte[] Crlf(string s) => Encoding.UTF8.GetBytes(s.Replace("\r\n", "\n").Replace("\n", "\r\n"));
  22. private static string RawSha(byte[] b) => Convert.ToHexString(SHA256.HashData(b));
  23. // TEST 1 — 文件 LF,库里存的是该 LF 文件的原始 hash → 必须通过
  24. [Fact]
  25. public void Test1_LfFile_StoredLfRawHash_Matches()
  26. {
  27. var file = Lf(SqlLf);
  28. Assert.True(MigrationScriptHash.Matches(RawSha(file), file));
  29. }
  30. // TEST 2 — 文件 LF,库里存的是等价 CRLF 版本的 hash → 必须通过
  31. [Fact]
  32. public void Test2_LfFile_StoredCrlfEquivalentHash_Matches()
  33. {
  34. var file = Lf(SqlLf);
  35. var storedFromWindows = RawSha(Crlf(SqlLf));
  36. Assert.NotEqual(RawSha(file), storedFromWindows); // 前提:两者 hash 确实不同
  37. Assert.True(MigrationScriptHash.Matches(storedFromWindows, file));
  38. }
  39. // TEST 3 — 文件 CRLF,库里存的是等价 LF 版本的 hash → 必须通过
  40. [Fact]
  41. public void Test3_CrlfFile_StoredLfEquivalentHash_Matches()
  42. {
  43. var file = Crlf(SqlLf);
  44. var storedFromLinux = RawSha(Lf(SqlLf));
  45. Assert.NotEqual(RawSha(file), storedFromLinux);
  46. Assert.True(MigrationScriptHash.Matches(storedFromLinux, file));
  47. }
  48. // TEST 4 — 文件 CRLF,库里存的就是该 CRLF 文件的 hash → 必须通过
  49. [Fact]
  50. public void Test4_CrlfFile_StoredCrlfHash_Matches()
  51. {
  52. var file = Crlf(SqlLf);
  53. Assert.True(MigrationScriptHash.Matches(RawSha(file), file));
  54. }
  55. // TEST 5 — SQL 被真实改写(CREATE TABLE A → B)→ 必须拒绝
  56. [Fact]
  57. public void Test5_RealSqlMutation_Rejected()
  58. {
  59. var stored = RawSha(Lf(SqlLf));
  60. var mutated = Lf(SqlLf.Replace("CREATE TABLE A", "CREATE TABLE B"));
  61. Assert.False(MigrationScriptHash.Matches(stored, mutated));
  62. }
  63. // TEST 6 — 增删 SQL token → 必须拒绝
  64. [Fact]
  65. public void Test6_TokenAddedOrRemoved_Rejected()
  66. {
  67. var stored = RawSha(Lf(SqlLf));
  68. var added = Lf(SqlLf.Replace("id BIGINT NOT NULL", "id BIGINT NOT NULL, name VARCHAR(10)"));
  69. Assert.False(MigrationScriptHash.Matches(stored, added));
  70. var removed = Lf(SqlLf.Replace(" NOT NULL", string.Empty));
  71. Assert.False(MigrationScriptHash.Matches(stored, removed));
  72. }
  73. // TEST 7 — 只改变换行(含孤立 CR 的 Mac 老式行尾)→ 必须通过
  74. [Fact]
  75. public void Test7_OnlyLineEndingChanged_Matches()
  76. {
  77. var stored = RawSha(Lf(SqlLf));
  78. Assert.True(MigrationScriptHash.Matches(stored, Crlf(SqlLf)));
  79. var crOnly = Encoding.UTF8.GetBytes(SqlLf.Replace("\r\n", "\n").Replace("\n", "\r"));
  80. Assert.True(MigrationScriptHash.Matches(stored, crOnly));
  81. }
  82. // TEST 8 — 新 migration 持久化的 hash 必须等于 canonical LF hash,且与平台无关
  83. [Fact]
  84. public void Test8_PersistedHashIsCanonicalLf_AndPlatformIndependent()
  85. {
  86. var lf = Lf(SqlLf);
  87. var crlf = Crlf(SqlLf);
  88. Assert.Equal(RawSha(lf), MigrationScriptHash.ComputeCanonical(lf));
  89. // 同一份 SQL,无论从哪种行尾的检出执行,写库的 canonical hash 必须一致
  90. Assert.Equal(MigrationScriptHash.ComputeCanonical(lf), MigrationScriptHash.ComputeCanonical(crlf));
  91. }
  92. // 空白差异不属于行尾差异,必须继续拒绝(防止 Matches 被误当成 Trim/去空白)
  93. [Fact]
  94. public void WhitespaceOtherThanLineEndings_StillRejected()
  95. {
  96. var stored = RawSha(Lf(SqlLf));
  97. var extraSpace = Lf(SqlLf.Replace("id BIGINT", "id BIGINT"));
  98. Assert.False(MigrationScriptHash.Matches(stored, extraSpace));
  99. var trailingSpace = Lf(SqlLf.Replace("CREATE TABLE A (", "CREATE TABLE A ( "));
  100. Assert.False(MigrationScriptHash.Matches(stored, trailingSpace));
  101. }
  102. // 空/缺失 stored hash 不得被当成匹配
  103. [Fact]
  104. public void EmptyStoredHash_NeverMatches()
  105. {
  106. var file = Lf(SqlLf);
  107. Assert.False(MigrationScriptHash.Matches(null, file));
  108. Assert.False(MigrationScriptHash.Matches(string.Empty, file));
  109. Assert.False(MigrationScriptHash.Matches(" ", file));
  110. }
  111. // 大小写不敏感(库里历史记录可能是小写十六进制)
  112. [Fact]
  113. public void StoredHashCaseInsensitive()
  114. {
  115. var file = Lf(SqlLf);
  116. Assert.True(MigrationScriptHash.Matches(RawSha(file).ToLowerInvariant(), file));
  117. }
  118. }