// DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 v0.1.2 §2.4「L2 DDL 哈希校验」。 // // 目的:目标态 DDL 是**跨仓双份**存在的(文档 `unified/03-migration/academic-model-ddl.sql` // 与代码仓 fixture `internal/academic/model/testdata/academic-model-ddl.sql`)。文档不在代码仓, // 跨仓字节相等无法由 Go 测试直接判定,因此本仓这一侧做「指纹锁」: // // sha256(fixture, LF 归一) == testdata/academic-model-ddl.sha256 的内容 // // 配合 AC-17 的人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256 并与锁文件比对, // 三者一致才视为"文档与代码未漂移",结果写进一致性报告留证。 // // 纪律(任一 DDL 变更必须**同一提交**内更新三处): // 1. 文档 `unified/03-migration/academic-model-ddl.sql`; // 2. 本仓 fixture `testdata/academic-model-ddl.sql`; // 3. 本仓锁文件 `testdata/academic-model-ddl.sha256`(单行 64 位小写 hex)。 // // 为什么按「LF 归一」取指纹(不是裸字节): // // 开发者机器常见 `core.autocrlf=true`(Windows 检出为 CRLF),同一内容在 Linux CI 会得到 // 不同 sha256 → 裸字节指纹必然产生"跨平台假红"。归一掉换行风格后,指纹只对**内容**敏感: // 任何字符级修改都会改变它,而"纯换行风格"这种无意义差异不会误报。 // (实测:Windows 工作区与 git blob 均为 LF,两者指纹一致 = 7a25c918…75935。) package model import ( "crypto/sha256" "encoding/hex" "os" "strings" "testing" ) const ( // ddlFixturePath DDL fixture 相对路径(go test 以包目录为工作目录)。 ddlFixturePath = "testdata/academic-model-ddl.sql" // ddlHashPath DDL 指纹锁文件路径(单行 64 位小写 hex,可被 awk '{print $1}' 直接取用)。 ddlHashPath = "testdata/academic-model-ddl.sha256" // ddlHashHexLen sha256 十六进制串长度。 ddlHashHexLen = 64 ) // normalizeEOL 把 CRLF / 孤立 CR 统一为 LF(跨平台指纹稳定的唯一前提)。 // // 入参:b []byte——文件原始字节。出参:[]byte——换行归一后的字节(不改其它任何字符)。 func normalizeEOL(b []byte) []byte { s := strings.ReplaceAll(string(b), "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") return []byte(s) } // sha256Hex 计算内容的 sha256 十六进制摘要(入参应已归一)。 // // 入参:b []byte。出参:64 位小写 hex。 func sha256Hex(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } // readDDLHashLock 读取并校验锁文件格式(单行 64 位 hex;多余空白容忍,注释/多行拒绝)。 // // 出参:锁文件中的 hex 摘要(小写)。 func readDDLHashLock(t *testing.T) string { t.Helper() raw, err := os.ReadFile(ddlHashPath) if err != nil { t.Fatalf("读取 DDL 指纹锁失败(%s): %v", ddlHashPath, err) } lines := make([]string, 0, 2) for _, line := range strings.Split(string(normalizeEOL(raw)), "\n") { if trimmed := strings.TrimSpace(line); trimmed != "" { lines = append(lines, trimmed) } } if len(lines) != 1 { t.Fatalf("锁文件 %s 应只含 1 行(64 位小写 hex),实得 %d 行: %v", ddlHashPath, len(lines), lines) } got := lines[0] if len(got) != ddlHashHexLen { t.Fatalf("锁文件 %s 内容长度 = %d,期望 %d(纯 hex,勿加注释/文件名)", ddlHashPath, len(got), ddlHashHexLen) } if strings.ToLower(got) != got { t.Fatalf("锁文件 %s 必须使用小写 hex,实得 %q", ddlHashPath, got) } for _, r := range got { if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { t.Fatalf("锁文件 %s 含非 hex 字符: %q", ddlHashPath, got) } } return got } // TestDDLFixtureHashLock fixture 内容必须与指纹锁一致(DDL 漂移的硬门禁)。 func TestDDLFixtureHashLock(t *testing.T) { want := readDDLHashLock(t) raw, err := os.ReadFile(ddlFixturePath) if err != nil { t.Fatalf("读取 DDL fixture 失败(%s): %v", ddlFixturePath, err) } got := sha256Hex(normalizeEOL(raw)) if got != want { t.Fatalf("DDL fixture 指纹漂移:\n 实际 = %s\n 锁定 = %s\n"+ "若为有意的 DDL 变更,必须**同一提交**内同步三处:文档副本 / fixture / testdata/academic-model-ddl.sha256", got, want) } } // TestDDLFixtureHashMatchesEmbedded embed 进测试二进制的 DDL 与磁盘 fixture 内容一致 // (防"改了 fixture 但 embed 走缓存/路径不一致"这类隐蔽漂移)。 func TestDDLFixtureHashMatchesEmbedded(t *testing.T) { raw, err := os.ReadFile(ddlFixturePath) if err != nil { t.Fatalf("读取 DDL fixture 失败: %v", err) } if got, want := sha256Hex(normalizeEOL([]byte(ddlSQL))), sha256Hex(normalizeEOL(raw)); got != want { t.Fatalf("go:embed 的 DDL 与磁盘 fixture 不一致:embed=%s file=%s", got, want) } }