From cbdfedab3850f2ac682ac7e4243cacd36f2b1665 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 20 May 2026 15:44:00 +0800 Subject: [PATCH] =?UTF-8?q?test(repository):=20=E8=A1=A5=E5=85=85=20AES=20?= =?UTF-8?q?Encryptor=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 AESEncryptor(AES-256-GCM)新增纯单元测试,覆盖: - NewAESEncryptor:合法 32 字节密钥、错误密钥长度(16/24/其他)、空 key 与非法 hex 三条路径 - 加解密往返:ASCII、中文多字节、空字符串、长字符串(> 1 KB)、特殊字符 - Nonce 随机性:相同明文 30 次加密均产生不同密文 - Decrypt 错误路径:非 base64 输入、长度过短、篡改密文体、篡改 GCM 标签 - 跨实例:相同密钥可互解,不同密钥不可互解 仅新增测试文件,不修改任何业务代码。 带 //go:build unit tag,与 go test -tags=unit 入口一致。 --- .../internal/repository/aes_encryptor_test.go | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 backend/internal/repository/aes_encryptor_test.go diff --git a/backend/internal/repository/aes_encryptor_test.go b/backend/internal/repository/aes_encryptor_test.go new file mode 100644 index 00000000..25bff622 --- /dev/null +++ b/backend/internal/repository/aes_encryptor_test.go @@ -0,0 +1,219 @@ +//go:build unit + +package repository + +import ( + "encoding/base64" + "encoding/hex" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── 测试辅助 ───────────────────────────────────────────────────────────────── + +// aesHexKey 构造一个全填充为 b 的 n 字节密钥并以 hex 编码返回。 +func aesHexKey(n int, b byte) string { + raw := make([]byte, n) + for i := range raw { + raw[i] = b + } + return hex.EncodeToString(raw) +} + +// aesTestCfg 用给定 hex 密钥字符串构造最小 Config。 +func aesTestCfg(keyHex string) *config.Config { + return &config.Config{ + Totp: config.TotpConfig{EncryptionKey: keyHex}, + } +} + +// aesEncryptor 创建一个持有合法 32 字节密钥的加密器,测试失败时立即终止。 +func aesEncryptor(t *testing.T) *AESEncryptor { + t.Helper() + enc, err := NewAESEncryptor(aesTestCfg(aesHexKey(32, 0x42))) + require.NoError(t, err) + require.NotNil(t, enc) + return enc.(*AESEncryptor) +} + +// ── NewAESEncryptor ────────────────────────────────────────────────────────── + +func TestNewAESEncryptor_ValidKey32Bytes(t *testing.T) { + enc, err := NewAESEncryptor(aesTestCfg(aesHexKey(32, 0x01))) + require.NoError(t, err) + require.NotNil(t, enc) +} + +// 16 / 24 字节密钥在 AES 体系内合法,但本实现仅接受 AES-256(32 字节)。 +func TestNewAESEncryptor_WrongKeyLength(t *testing.T) { + tests := []struct { + name string + keySize int + }{ + {"16_bytes_AES128", 16}, + {"24_bytes_AES192", 24}, + {"1_byte", 1}, + {"31_bytes", 31}, + {"33_bytes", 33}, + {"64_bytes", 64}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewAESEncryptor(aesTestCfg(aesHexKey(tt.keySize, 0x00))) + require.Error(t, err) + assert.Contains(t, err.Error(), "32 bytes") + }) + } +} + +// "配置缺失"场景:空字符串与非法 hex 编码。 +func TestNewAESEncryptor_MissingOrInvalidConfig(t *testing.T) { + tests := []struct { + name string + keyHex string + wantContain string + }{ + {"empty_key", "", "32 bytes"}, + {"invalid_hex_odd_length", "abcde", "invalid totp encryption key"}, + {"invalid_hex_chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "invalid totp encryption key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewAESEncryptor(aesTestCfg(tt.keyHex)) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantContain) + }) + } +} + +// ── 加解密往返(Roundtrip)─────────────────────────────────────────────────── + +func TestAESEncryptor_RoundTrip(t *testing.T) { + enc := aesEncryptor(t) + + tests := []struct { + name string + plaintext string + }{ + {"ascii", "Hello, Sub2API!"}, + {"chinese_multibyte", "你好,世界!这是多字节 UTF-8 文本。"}, + {"empty_string", ""}, + {"long_string_gt_1KB", strings.Repeat("x", 2048)}, + {"special_chars", "!@#$%^&*()_+-=[]{}|;':\",./<>?"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ct, err := enc.Encrypt(tt.plaintext) + require.NoError(t, err) + require.NotEmpty(t, ct, "密文不应为空(即便明文为空字符串)") + + got, err := enc.Decrypt(ct) + require.NoError(t, err) + assert.Equal(t, tt.plaintext, got) + }) + } +} + +// ── IV/Nonce 随机性 ────────────────────────────────────────────────────────── + +func TestAESEncryptor_Encrypt_NonceRandomness(t *testing.T) { + enc := aesEncryptor(t) + const iterations = 30 + plaintext := "same plaintext for every iteration" + + seen := make(map[string]struct{}, iterations) + for i := 0; i < iterations; i++ { + ct, err := enc.Encrypt(plaintext) + require.NoError(t, err) + seen[ct] = struct{}{} + } + + // 30 次加密相同明文,每次因随机 Nonce 应产生不同密文。 + assert.Len(t, seen, iterations, + "每次加密应因随机 Nonce 产生唯一密文,共 %d 次", iterations) +} + +// ── Decrypt 错误路径 ────────────────────────────────────────────────────────── + +func TestAESDecrypt_InvalidBase64(t *testing.T) { + enc := aesEncryptor(t) + _, err := enc.Decrypt("!!!not-valid-base64!!!") + require.Error(t, err) + assert.Contains(t, err.Error(), "decode base64") +} + +func TestAESDecrypt_TooShort(t *testing.T) { + enc := aesEncryptor(t) + // GCM Nonce 为 12 字节;仅提供 2 字节,必然短于 NonceSize。 + short := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02}) + _, err := enc.Decrypt(short) + require.Error(t, err) + assert.Contains(t, err.Error(), "too short") +} + +func TestAESDecrypt_TamperedCiphertext(t *testing.T) { + enc := aesEncryptor(t) + + ct, err := enc.Encrypt("sensitive payload") + require.NoError(t, err) + + raw, err := base64.StdEncoding.DecodeString(ct) + require.NoError(t, err) + + // Nonce 占前 12 字节;翻转其后第一个字节(密文体)。 + raw[12] ^= 0xFF + _, err = enc.Decrypt(base64.StdEncoding.EncodeToString(raw)) + require.Error(t, err, "篡改密文体后解密应失败") +} + +func TestAESDecrypt_TamperedTag(t *testing.T) { + enc := aesEncryptor(t) + + ct, err := enc.Encrypt("sensitive payload") + require.NoError(t, err) + + raw, err := base64.StdEncoding.DecodeString(ct) + require.NoError(t, err) + + // GCM 认证标签占最后 16 字节;翻转最后一个字节。 + raw[len(raw)-1] ^= 0xFF + _, err = enc.Decrypt(base64.StdEncoding.EncodeToString(raw)) + require.Error(t, err, "篡改 GCM 标签后解密应失败") +} + +// ── 跨实例(Cross-instance)────────────────────────────────────────────────── + +func TestAESEncryptor_CrossInstance_SameKey_CanDecrypt(t *testing.T) { + keyHex := aesHexKey(32, 0xDE) + + enc1, err := NewAESEncryptor(aesTestCfg(keyHex)) + require.NoError(t, err) + enc2, err := NewAESEncryptor(aesTestCfg(keyHex)) + require.NoError(t, err) + + plaintext := "cross-instance roundtrip" + ct, err := enc1.Encrypt(plaintext) + require.NoError(t, err) + + got, err := enc2.Decrypt(ct) + require.NoError(t, err) + assert.Equal(t, plaintext, got, "相同密钥构造的两个实例应可互相解密") +} + +func TestAESEncryptor_CrossInstance_DifferentKey_CannotDecrypt(t *testing.T) { + enc1, err := NewAESEncryptor(aesTestCfg(aesHexKey(32, 0xAA))) + require.NoError(t, err) + enc2, err := NewAESEncryptor(aesTestCfg(aesHexKey(32, 0xBB))) + require.NoError(t, err) + + ct, err := enc1.Encrypt("secret message") + require.NoError(t, err) + + _, err = enc2.Decrypt(ct) + require.Error(t, err, "不同密钥的实例不应能解密对方的密文") +}