ddl_hash_test.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. // DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 v0.1.2 §2.4「L2 DDL 哈希校验」。
  2. //
  3. // 目的:目标态 DDL 是**跨仓双份**存在的(文档 `unified/03-migration/academic-model-ddl.sql`
  4. // 与代码仓 fixture `internal/academic/model/testdata/academic-model-ddl.sql`)。文档不在代码仓,
  5. // 跨仓字节相等无法由 Go 测试直接判定,因此本仓这一侧做「指纹锁」:
  6. //
  7. // sha256(fixture, LF 归一) == testdata/academic-model-ddl.sha256 的内容
  8. //
  9. // 配合 AC-17 的人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256 并与锁文件比对,
  10. // 三者一致才视为"文档与代码未漂移",结果写进一致性报告留证。
  11. //
  12. // 纪律(任一 DDL 变更必须**同一提交**内更新三处):
  13. // 1. 文档 `unified/03-migration/academic-model-ddl.sql`;
  14. // 2. 本仓 fixture `testdata/academic-model-ddl.sql`;
  15. // 3. 本仓锁文件 `testdata/academic-model-ddl.sha256`(单行 64 位小写 hex)。
  16. //
  17. // 为什么按「LF 归一」取指纹(不是裸字节):
  18. //
  19. // 开发者机器常见 `core.autocrlf=true`(Windows 检出为 CRLF),同一内容在 Linux CI 会得到
  20. // 不同 sha256 → 裸字节指纹必然产生"跨平台假红"。归一掉换行风格后,指纹只对**内容**敏感:
  21. // 任何字符级修改都会改变它,而"纯换行风格"这种无意义差异不会误报。
  22. // (实测:Windows 工作区与 git blob 均为 LF,两者指纹一致 = 7a25c918…75935。)
  23. package model
  24. import (
  25. "crypto/sha256"
  26. "encoding/hex"
  27. "os"
  28. "strings"
  29. "testing"
  30. )
  31. const (
  32. // ddlFixturePath DDL fixture 相对路径(go test 以包目录为工作目录)。
  33. ddlFixturePath = "testdata/academic-model-ddl.sql"
  34. // ddlHashPath DDL 指纹锁文件路径(单行 64 位小写 hex,可被 awk '{print $1}' 直接取用)。
  35. ddlHashPath = "testdata/academic-model-ddl.sha256"
  36. // ddlHashHexLen sha256 十六进制串长度。
  37. ddlHashHexLen = 64
  38. )
  39. // normalizeEOL 把 CRLF / 孤立 CR 统一为 LF(跨平台指纹稳定的唯一前提)。
  40. //
  41. // 入参:b []byte——文件原始字节。出参:[]byte——换行归一后的字节(不改其它任何字符)。
  42. func normalizeEOL(b []byte) []byte {
  43. s := strings.ReplaceAll(string(b), "\r\n", "\n")
  44. s = strings.ReplaceAll(s, "\r", "\n")
  45. return []byte(s)
  46. }
  47. // sha256Hex 计算内容的 sha256 十六进制摘要(入参应已归一)。
  48. //
  49. // 入参:b []byte。出参:64 位小写 hex。
  50. func sha256Hex(b []byte) string {
  51. sum := sha256.Sum256(b)
  52. return hex.EncodeToString(sum[:])
  53. }
  54. // readDDLHashLock 读取并校验锁文件格式(单行 64 位 hex;多余空白容忍,注释/多行拒绝)。
  55. //
  56. // 出参:锁文件中的 hex 摘要(小写)。
  57. func readDDLHashLock(t *testing.T) string {
  58. t.Helper()
  59. raw, err := os.ReadFile(ddlHashPath)
  60. if err != nil {
  61. t.Fatalf("读取 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
  62. }
  63. lines := make([]string, 0, 2)
  64. for _, line := range strings.Split(string(normalizeEOL(raw)), "\n") {
  65. if trimmed := strings.TrimSpace(line); trimmed != "" {
  66. lines = append(lines, trimmed)
  67. }
  68. }
  69. if len(lines) != 1 {
  70. t.Fatalf("锁文件 %s 应只含 1 行(64 位小写 hex),实得 %d 行: %v", ddlHashPath, len(lines), lines)
  71. }
  72. got := lines[0]
  73. if len(got) != ddlHashHexLen {
  74. t.Fatalf("锁文件 %s 内容长度 = %d,期望 %d(纯 hex,勿加注释/文件名)", ddlHashPath, len(got), ddlHashHexLen)
  75. }
  76. if strings.ToLower(got) != got {
  77. t.Fatalf("锁文件 %s 必须使用小写 hex,实得 %q", ddlHashPath, got)
  78. }
  79. for _, r := range got {
  80. if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
  81. t.Fatalf("锁文件 %s 含非 hex 字符: %q", ddlHashPath, got)
  82. }
  83. }
  84. return got
  85. }
  86. // TestDDLFixtureHashLock fixture 内容必须与指纹锁一致(DDL 漂移的硬门禁)。
  87. func TestDDLFixtureHashLock(t *testing.T) {
  88. want := readDDLHashLock(t)
  89. raw, err := os.ReadFile(ddlFixturePath)
  90. if err != nil {
  91. t.Fatalf("读取 DDL fixture 失败(%s): %v", ddlFixturePath, err)
  92. }
  93. got := sha256Hex(normalizeEOL(raw))
  94. if got != want {
  95. t.Fatalf("DDL fixture 指纹漂移:\n 实际 = %s\n 锁定 = %s\n"+
  96. "若为有意的 DDL 变更,必须**同一提交**内同步三处:文档副本 / fixture / testdata/academic-model-ddl.sha256",
  97. got, want)
  98. }
  99. }
  100. // TestDDLFixtureHashMatchesEmbedded embed 进测试二进制的 DDL 与磁盘 fixture 内容一致
  101. // (防"改了 fixture 但 embed 走缓存/路径不一致"这类隐蔽漂移)。
  102. func TestDDLFixtureHashMatchesEmbedded(t *testing.T) {
  103. raw, err := os.ReadFile(ddlFixturePath)
  104. if err != nil {
  105. t.Fatalf("读取 DDL fixture 失败: %v", err)
  106. }
  107. if got, want := sha256Hex(normalizeEOL([]byte(ddlSQL))), sha256Hex(normalizeEOL(raw)); got != want {
  108. t.Fatalf("go:embed 的 DDL 与磁盘 fixture 不一致:embed=%s file=%s", got, want)
  109. }
  110. }