Files
martin a6ed67d841 fix: 完成 P3 细节优化项
依据《代码分析结果.md》P3 章节,逐条修复 20 项细节问题:

正确性修复:
- P3-1 锁定剩余分钟改用 math.Ceil,避免「0.9 分钟」显示为「0 分钟」
- P3-12 移除 CheckAndUpgradeLevel 中恒真的 hasUpdate 标志
- P3-14 移除 CheckTrialExpiry 中恒真表达式(count > 0 || true)调试残留,改用 RowsAffected
- P3-2 成员不存在统一返回 NewNotFoundError(404),与其它方法语义一致

安全加固:
- P3-6 X-Request-Id 加格式校验 ^[A-Za-z0-9-]{1,64}$,防日志注入
- P3-7 新增公共 EscapeLikePattern,转义 admin LIKE 搜索的 %/_ 通配符
- P3-8 Encrypt 输出加 v1: 版本前缀,Decrypt 兼容无前缀历史密文
- P3-9 turnstile 响应体读取加 64KB 上限,防内存耗尽
- P3-11 backup codes 熵由 48-bit 提升到 64-bit
- P3-15 recordAudit 对 multipart/二进制体跳过 Unmarshal 与原文落库
- P3-17 错误日志按 Content-Type 跳过 multipart/二进制体读取
- P3-16 HandleRealtime 加 panic recover 与连接生命周期日志

可维护性/一致性:
- P3-3 oauth.go 时间格式统一为 time.RFC3339
- P3-5 MsgSuccess 统一为 ok,response.go 改用常量引用
- P3-4 错误码分段策略加注释,说明空洞与跳跃的成因
- P3-13 ModelMultiplier 占位语义加注释(计费公式设计位 + 快照列)
- P3-19 抽取 registerCronJobs,主启动流程清爽

重构:
- P3-18 删除死代码 BuildBillingSummary/SummaryParams(仅测试引用),保留共用 helper
- P3-20 抽取 relay.InvalidateMemberModelScopeCache 单一失效入口,
  消除三处 member_model 前缀字面量重复(原为有意跨包失效,原建议改名会破坏)

经核实保留 + 文档化:
- P3-10 TOTP 暂保留 SHA-1:totp.Validate 硬编码 SHA-1 且存储不保存算法元信息,
  直接切 SHA-256 会导致校验恒失败,需配合 schema 改动,超出 P3 范围
2026-07-26 12:16:33 +08:00

189 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"strings"
"testing"
)
// key32 returns a deterministic 32-byte AES-256 key filled with b.
func key32(b byte) []byte {
return bytes.Repeat([]byte{b}, 32)
}
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
key := key32('A')
cases := []string{
"",
"hello",
"sk-proj-1234567890abcdef",
strings.Repeat("长文本测试", 100), // 多字节 + 较长
}
for _, plain := range cases {
enc, err := EncryptString(key, plain)
if err != nil {
t.Fatalf("EncryptString(%q) error: %v", plain, err)
}
if enc == plain && plain != "" {
t.Errorf("ciphertext equals plaintext for %q", plain)
}
got, err := DecryptString(key, enc)
if err != nil {
t.Fatalf("DecryptString error: %v", err)
}
if got != plain {
t.Errorf("round-trip mismatch: got %q, want %q", got, plain)
}
}
}
func TestEncrypt_NonceIsRandom(t *testing.T) {
key := key32('A')
a, _ := EncryptString(key, "same-plaintext")
b, _ := EncryptString(key, "same-plaintext")
if a == b {
t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce not random)")
}
// 两者都应能正确解密
for _, c := range []string{a, b} {
got, err := DecryptString(key, c)
if err != nil || got != "same-plaintext" {
t.Errorf("decrypt failed: got %q err %v", got, err)
}
}
}
func TestEncryptDecrypt_InvalidKeyLength(t *testing.T) {
for _, badKey := range [][]byte{nil, make([]byte, 16), make([]byte, 31), make([]byte, 33)} {
if _, err := Encrypt(badKey, []byte("x")); !errors.Is(err, ErrInvalidKeyLength) {
t.Errorf("Encrypt with key len %d: got %v, want ErrInvalidKeyLength", len(badKey), err)
}
if _, err := Decrypt(badKey, "AAAA"); !errors.Is(err, ErrInvalidKeyLength) {
t.Errorf("Decrypt with key len %d: got %v, want ErrInvalidKeyLength", len(badKey), err)
}
}
}
func TestDecrypt_WrongKeyFails(t *testing.T) {
enc, err := EncryptString(key32('A'), "secret")
if err != nil {
t.Fatal(err)
}
if _, err := DecryptString(key32('B'), enc); err == nil {
t.Error("decryption with a different key must fail (GCM auth), got nil error")
}
}
func TestDecrypt_TamperedCiphertextFails(t *testing.T) {
key := key32('A')
enc, err := EncryptString(key, "secret-payload")
if err != nil {
t.Fatal(err)
}
// 剥离 v1: 版本前缀后取 base64 密文
payload := strings.TrimPrefix(enc, "v1:")
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatal(err)
}
// 翻转最后一个字节(密文/认证标签部分)
raw[len(raw)-1] ^= 0xFF
tampered := "v1:" + base64.StdEncoding.EncodeToString(raw)
if _, err := DecryptString(key, tampered); err == nil {
t.Error("tampered ciphertext must fail authentication, got nil error")
}
}
func TestEncrypt_HasVersionPrefix(t *testing.T) {
key := key32('A')
enc, err := EncryptString(key, "payload")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(enc, "v1:") {
t.Errorf("encrypted value should carry the v1: version prefix, got %q", enc)
}
// 旧格式(无前缀)密文必须仍能解密(向后兼容存量数据)
legacy := base64.StdEncoding.EncodeToString(rawGcmCiphertext(key, []byte("legacy")))
got, err := DecryptString(key, legacy)
if err != nil || string(got) != "legacy" {
t.Errorf("legacy unprefixed ciphertext must still decrypt: got %q err %v", got, err)
}
}
// rawGcmCiphertext 用与 Encrypt 相同的算法生成无前缀的密文,用于模拟历史数据。
func rawGcmCiphertext(key, plaintext []byte) []byte {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
_, _ = rand.Read(nonce)
return gcm.Seal(nonce, nonce, plaintext, nil)
}
func TestDecrypt_TooShortAndInvalidBase64(t *testing.T) {
key := key32('A')
// 合法 base64 但长度不足 nonce
short := base64.StdEncoding.EncodeToString([]byte("tiny"))
if _, err := Decrypt(key, short); !errors.Is(err, ErrInvalidCiphertext) {
t.Errorf("too-short ciphertext: got %v, want ErrInvalidCiphertext", err)
}
// 非法 base64
if _, err := Decrypt(key, "!!!not-base64!!!"); err == nil {
t.Error("invalid base64 must error, got nil")
}
}
func TestMustGetEncryptionKey(t *testing.T) {
// 合法:64 个 hex 字符 = 32 字节
key := MustGetEncryptionKey(strings.Repeat("ab", 32))
if len(key) != 32 {
t.Errorf("key length = %d, want 32", len(key))
}
// 非法 hex 应 panic
assertPanics(t, "invalid hex", func() { MustGetEncryptionKey("zzzz") })
// 长度不足应 panic(32 个 hex = 16 字节)
assertPanics(t, "wrong length", func() { MustGetEncryptionKey(strings.Repeat("ab", 16)) })
}
func TestHashAndVerifyPassword(t *testing.T) {
const pw = "S3cretP@ssw0rd"
hash, err := HashPassword(pw)
if err != nil {
t.Fatalf("HashPassword error: %v", err)
}
if hash == pw {
t.Error("hash must not equal the plaintext password")
}
if !VerifyPassword(pw, hash) {
t.Error("correct password failed verification")
}
if VerifyPassword("wrong-password", hash) {
t.Error("wrong password passed verification")
}
// 加盐:同一密码两次哈希应不同,但都能验证
hash2, _ := HashPassword(pw)
if hash2 == hash {
t.Error("two hashes of the same password are identical (no salt)")
}
if !VerifyPassword(pw, hash2) {
t.Error("second hash failed verification")
}
}
func assertPanics(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if r := recover(); r == nil {
t.Errorf("%s: expected panic, got none", name)
}
}()
fn()
}