From f3b4be9b2a60ec11994a50fd7abf93eadd882dd3 Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 4 Jun 2026 11:36:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(test)=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/relay/apikey_test.go | 78 ++++++++++ internal/logic/relay/health_test.go | 111 ++++++++++++++ internal/logic/relay/provider_test.go | 182 ++++++++++++++++++++++ internal/middleware/helpers_test.go | 187 +++++++++++++++++++++++ internal/utility/crypto/crypto_test.go | 157 +++++++++++++++++++ internal/utility/totp/totp_test.go | 90 +++++++++++ relay/channel/openai/converter_test.go | 201 +++++++++++++++++++++++++ 7 files changed, 1006 insertions(+) create mode 100644 internal/logic/relay/apikey_test.go create mode 100644 internal/logic/relay/health_test.go create mode 100644 internal/logic/relay/provider_test.go create mode 100644 internal/middleware/helpers_test.go create mode 100644 internal/utility/crypto/crypto_test.go create mode 100644 internal/utility/totp/totp_test.go create mode 100644 relay/channel/openai/converter_test.go diff --git a/internal/logic/relay/apikey_test.go b/internal/logic/relay/apikey_test.go new file mode 100644 index 0000000..4502095 --- /dev/null +++ b/internal/logic/relay/apikey_test.go @@ -0,0 +1,78 @@ +package relay + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestApiKeyHash(t *testing.T) { + const key = "sk-abcdef123456" + + got := apiKeyHash(key) + + // 契约:apiKeyHash 等价于 hex(sha256(key)) + sum := sha256.Sum256([]byte(key)) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Errorf("apiKeyHash(%q) = %s, want %s", key, got, want) + } + if len(got) != 64 { + t.Errorf("hash length = %d, want 64 hex chars", len(got)) + } + // 确定性 + if apiKeyHash(key) != got { + t.Error("apiKeyHash is not deterministic") + } + // 不同输入 -> 不同输出 + if apiKeyHash("sk-different-key") == got { + t.Error("different inputs produced the same hash") + } +} + +func TestDefaultChannelSettings(t *testing.T) { + s := DefaultChannelSettings() + if s.TimeoutSeconds != 60 { + t.Errorf("default TimeoutSeconds = %d, want 60", s.TimeoutSeconds) + } + if s.RetryCount != 1 { + t.Errorf("default RetryCount = %d, want 1", s.RetryCount) + } +} + +func TestParseChannelSettings_Defaults(t *testing.T) { + for _, in := range []string{"", "{}", "null"} { + s := ParseChannelSettings(in) + if s.TimeoutSeconds != 60 || s.RetryCount != 1 { + t.Errorf("ParseChannelSettings(%q) = %+v, want defaults (60,1)", in, s) + } + } +} + +func TestParseChannelSettings_InvalidJSONFallsBackToDefaults(t *testing.T) { + s := ParseChannelSettings("{not valid json") + if s.TimeoutSeconds != 60 || s.RetryCount != 1 { + t.Errorf("invalid JSON should fall back to defaults, got %+v", s) + } +} + +func TestParseChannelSettings_PartialOverrideKeepsDefaults(t *testing.T) { + // 只覆盖 timeout,retry 缺省应回填为 1 + s := ParseChannelSettings(`{"timeout_seconds":30}`) + if s.TimeoutSeconds != 30 { + t.Errorf("TimeoutSeconds = %d, want 30", s.TimeoutSeconds) + } + if s.RetryCount != 1 { + t.Errorf("missing RetryCount should default to 1, got %d", s.RetryCount) + } +} + +func TestParseChannelSettings_FullOverride(t *testing.T) { + s := ParseChannelSettings(`{"timeout_seconds":120,"retry_count":3}`) + if s.TimeoutSeconds != 120 { + t.Errorf("TimeoutSeconds = %d, want 120", s.TimeoutSeconds) + } + if s.RetryCount != 3 { + t.Errorf("RetryCount = %d, want 3", s.RetryCount) + } +} diff --git a/internal/logic/relay/health_test.go b/internal/logic/relay/health_test.go new file mode 100644 index 0000000..110afe3 --- /dev/null +++ b/internal/logic/relay/health_test.go @@ -0,0 +1,111 @@ +package relay + +import ( + "math" + "testing" +) + +const floatEps = 1e-6 + +func floatEq(a, b float64) bool { + return math.Abs(a-b) < floatEps +} + +func TestCalcLatencyScore(t *testing.T) { + tests := []struct { + name string + latency float64 + want float64 + }{ + {"zero", 0, 100}, + {"under 1s", 500, 100}, + {"exactly 1s", 1000, 100}, + {"1s-3s midpoint 2s", 2000, 65}, // 80 - (2000-1000)/2000*30 + {"exactly 3s", 3000, 50}, // 80 - (3000-1000)/2000*30 + {"3s-10s midpoint 6.5s", 6500, 35}, // 50 - (6500-3000)/7000*30 + {"exactly 10s", 10000, 20}, // 50 - (10000-3000)/7000*30 + {"over 10s", 20000, 20}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calcLatencyScore(tt.latency); !floatEq(got, tt.want) { + t.Errorf("calcLatencyScore(%.0f) = %.4f, want %.4f", tt.latency, got, tt.want) + } + }) + } +} + +func TestCalcHealthScore(t *testing.T) { + tests := []struct { + name string + successRate float64 + latencyMs float64 + stability float64 + consecutiveFax int + want float64 + }{ + // 全满分:100*.4 + 100*.25 + 100*.2 + 100*.15 = 100 + {"perfect", 100, 500, 100, 0, 100}, + // 全最差:0 + 20*.25 + 0 + 0 = 5 + {"worst", 0, 20000, 0, 5, 5}, + // 80*.4 + 65*.25(2s) + 90*.2 + 80*.15(1 fail) = 32 + 16.25 + 18 + 12 = 78.25 + {"mixed", 80, 2000, 90, 1, 78.25}, + // 连续失败分下限为 0(不为负):100*.4 + 100*.25 + 100*.2 + 0*.15 = 85 + {"fail score floored at zero", 100, 500, 100, 10, 85}, + // 失败 5 次即 0 分:100*.4 + 100*.25 + 100*.2 + 0 = 85 + {"five failures hits zero", 100, 500, 100, 5, 85}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calcHealthScore(tt.successRate, tt.latencyMs, tt.stability, tt.consecutiveFax) + if !floatEq(got, tt.want) { + t.Errorf("calcHealthScore(%.0f,%.0f,%.0f,%d) = %.4f, want %.4f", + tt.successRate, tt.latencyMs, tt.stability, tt.consecutiveFax, got, tt.want) + } + if got < 0 || got > 100 { + t.Errorf("score %.4f out of [0,100] range", got) + } + }) + } +} + +func TestCalcHealthScore_RoundedToTwoDecimals(t *testing.T) { + // 构造一个会产生多位小数的组合,验证四舍五入到两位 + got := calcHealthScore(33.333, 500, 33.333, 0) + // 33.333*.4 + 100*.25 + 33.333*.2 + 100*.15 = 13.3332 + 25 + 6.6666 + 15 = 59.9998 -> 60.00 + if !floatEq(got, 60) { + t.Errorf("got %.6f, want 60.00 (rounded)", got) + } +} + +func TestCalcStability(t *testing.T) { + tests := []struct { + name string + newLatency float64 + oldLatency float64 + current float64 + want float64 + }{ + {"no history returns 100", 1500, 0, 50, 100}, + {"negative old returns 100", 1500, -1, 50, 100}, + // 延迟无变化:cur*0.9 + 1*100*0.1 + {"stable latency keeps high", 1000, 1000, 100, 100}, // 90 + 10 + {"stable latency from 50", 1000, 1000, 50, 55}, // 45 + 10 + // 延迟翻倍:ratio 2 -> 0.5 -> cur*0.9 + 0.5*100*0.1 + {"latency doubled", 2000, 1000, 80, 77}, // 72 + 5 + // 延迟减半:ratio 0.5(保持)-> 对称,结果同上 + {"latency halved is symmetric", 500, 1000, 80, 77}, // 72 + 5 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calcStability(tt.newLatency, tt.oldLatency, tt.current) + if !floatEq(got, tt.want) { + t.Errorf("calcStability(%.0f,%.0f,%.0f) = %.4f, want %.4f", + tt.newLatency, tt.oldLatency, tt.current, got, tt.want) + } + if got < 0 || got > 100 { + t.Errorf("stability %.4f out of [0,100] range", got) + } + }) + } +} diff --git a/internal/logic/relay/provider_test.go b/internal/logic/relay/provider_test.go new file mode 100644 index 0000000..a7bf528 --- /dev/null +++ b/internal/logic/relay/provider_test.go @@ -0,0 +1,182 @@ +package relay + +import ( + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/qianfree/team-api/relay/common" +) + +func TestSafeUTF8Truncate(t *testing.T) { + tests := []struct { + name string + s string + maxLen int + }{ + {"shorter than max", "hello", 10}, + {"equal to max", "hello", 5}, + {"ascii cut", "hello world", 5}, + {"multibyte euro cut mid-rune", "a€b", 2}, // € = 3 bytes + {"multibyte euro cut mid-rune 2", "a€b", 3}, + {"cjk cut", "你好世界", 5}, // each rune 3 bytes + {"emoji cut", "🚀🚀", 3}, // each emoji 4 bytes + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := safeUTF8Truncate(tt.s, tt.maxLen) + if len(got) > tt.maxLen { + t.Errorf("result len %d exceeds maxLen %d", len(got), tt.maxLen) + } + if !utf8.ValidString(got) { + t.Errorf("result %q is not valid UTF-8 (truncated mid-rune)", got) + } + if !strings.HasPrefix(tt.s, got) { + t.Errorf("result %q is not a prefix of input %q", got, tt.s) + } + }) + } +} + +func TestTruncateBody_ShortReturnedVerbatim(t *testing.T) { + s := "short body" + if got := truncateBody(s, 100); got != s { + t.Errorf("short body should be returned verbatim, got %q", got) + } +} + +func TestTruncateBody_LongNonStreamTruncated(t *testing.T) { + s := strings.Repeat("x", 500) + got := truncateBody(s, 100) + if len(got) >= len(s) { + t.Errorf("expected truncation, got len %d (input %d)", len(got), len(s)) + } + if !strings.HasSuffix(got, "...[truncated]") { + t.Errorf("expected truncation marker suffix, got %q", got) + } +} + +func TestTruncateStreamBody_KeepsHeadAndTailDropsMiddle(t *testing.T) { + // 40 行短 SSE,每行 "data: Lxx"(9 字节)。尾部 20 行 = 199 字节。 + // maxLen=250 留出 headBudget>0,结果形如 head + marker + tail。 + var lines []string + for i := range 40 { + lines = append(lines, fmt.Sprintf("data: L%02d", i)) + } + s := strings.Join(lines, "\n") + + got := truncateStreamBody(s, 250) + + if len(got) > len(s) { + t.Fatalf("output longer than input: %d > %d", len(got), len(s)) + } + if !strings.Contains(got, "data: L00") { + t.Errorf("head line 'data: L00' should be kept, got:\n%s", got) + } + if !strings.Contains(got, "data: L39") { + t.Errorf("tail line 'data: L39' should be kept, got:\n%s", got) + } + if strings.Contains(got, "data: L10") { + t.Errorf("middle line 'data: L10' should have been dropped, got:\n%s", got) + } + if !strings.Contains(got, "...[truncated]...") { + t.Errorf("expected middle truncation marker, got:\n%s", got) + } +} + +func TestTruncateBody_RoutesStreamAndPreservesLastLine(t *testing.T) { + // 流式响应的关键信息(usage / [DONE])在末尾。普通截断会保留头部、丢掉末尾; + // 流式感知截断会保留尾部。以此验证 truncateBody 正确路由到流式分支。 + var lines []string + for i := range 39 { + lines = append(lines, fmt.Sprintf("data: L%02d", i)) + } + lines = append(lines, "data: [DONE]") + s := strings.Join(lines, "\n") + + got := truncateBody(s, 300) + if !strings.Contains(got, "[DONE]") { + t.Errorf("stream-aware truncation should preserve trailing [DONE], got:\n%s", got) + } + if len(got) >= len(s) { + t.Errorf("expected truncation, got len %d (input %d)", len(got), len(s)) + } +} + +func TestJSONNullIfEmpty(t *testing.T) { + if got := jsonNullIfEmpty(""); got != nil { + t.Errorf("empty string should map to nil, got %v", got) + } + if got := jsonNullIfEmpty("{}"); got != "{}" { + t.Errorf("non-empty string should pass through, got %v", got) + } +} + +func TestParseCapabilitiesJSON(t *testing.T) { + if got := parseCapabilitiesJSON(""); got != nil { + t.Errorf("empty -> nil, got %v", got) + } + if got := parseCapabilitiesJSON("{}"); got != nil { + t.Errorf("empty object -> nil, got %v", got) + } + if got := parseCapabilitiesJSON("not json"); got != nil { + t.Errorf("invalid json -> nil, got %v", got) + } + got := parseCapabilitiesJSON(`{"vision":true,"tools":false}`) + if got == nil { + t.Fatal("valid json should parse to a map") + } + if !got["vision"] || got["tools"] { + t.Errorf("parsed map wrong: %v", got) + } +} + +func TestBuildUsageLogDO_RequestTypeDerivation(t *testing.T) { + tests := []struct { + name string + requestType int + isStream bool + want int + }{ + {"explicit type preserved", 3, true, 3}, + {"derived stream", 0, true, 2}, + {"derived sync", 0, false, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := buildUsageLogDO(&common.UsageRecord{ + RequestType: tt.requestType, + IsStream: tt.isStream, + }) + got, ok := d.RequestType.(int) + if !ok || got != tt.want { + t.Errorf("RequestType = %v (%T), want %d", d.RequestType, d.RequestType, tt.want) + } + }) + } +} + +func TestBuildUsageLogDO_FieldMapping(t *testing.T) { + rec := &common.UsageRecord{ + TenantID: 7, + LatencyMs: 123.9, // 应被截断为 int 123 + BillingSnapshot: "", // 空 -> nil + } + d := buildUsageLogDO(rec) + + if d.TenantId != int64(7) { + t.Errorf("TenantId = %v, want 7", d.TenantId) + } + if got, ok := d.LatencyMs.(int); !ok || got != 123 { + t.Errorf("LatencyMs = %v (%T), want int 123", d.LatencyMs, d.LatencyMs) + } + if d.BillingSnapshot != nil { + t.Errorf("empty BillingSnapshot should map to nil, got %v", d.BillingSnapshot) + } + + rec2 := &common.UsageRecord{BillingSnapshot: `{"k":1}`} + if got := buildUsageLogDO(rec2).BillingSnapshot; got != `{"k":1}` { + t.Errorf("non-empty BillingSnapshot should pass through, got %v", got) + } +} diff --git a/internal/middleware/helpers_test.go b/internal/middleware/helpers_test.go new file mode 100644 index 0000000..ddfe9f8 --- /dev/null +++ b/internal/middleware/helpers_test.go @@ -0,0 +1,187 @@ +package middleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "testing" +) + +func TestComputeHMACSignature(t *testing.T) { + const ( + secret = "top-secret" + ts = "1700000000" + nonce = "abc123" + method = "POST" + path = "/open/v1/messages" + ) + body := []byte(`{"hello":"world"}`) + + got := computeHMACSignature(secret, ts, nonce, method, path, body) + + // 契约:HMAC-SHA256(secret, "ts\nnonce\nmethod\npath\nhex(sha256(body))") + bodyHash := sha256.Sum256(body) + msg := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", ts, nonce, method, path, hex.EncodeToString(bodyHash[:])) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(msg)) + want := hex.EncodeToString(mac.Sum(nil)) + + if got != want { + t.Errorf("signature = %s, want %s", got, want) + } + + // 确定性 + if computeHMACSignature(secret, ts, nonce, method, path, body) != got { + t.Error("signature is not deterministic") + } + // body 改变 -> 签名改变(请求完整性) + if computeHMACSignature(secret, ts, nonce, method, path, []byte(`{"hello":"WORLD"}`)) == got { + t.Error("changing the body must change the signature") + } + // secret 改变 -> 签名改变 + if computeHMACSignature("other-secret", ts, nonce, method, path, body) == got { + t.Error("changing the secret must change the signature") + } + // 空 body 与有 body 的签名必须不同 + if computeHMACSignature(secret, ts, nonce, method, path, nil) == got { + t.Error("empty body and non-empty body must differ") + } +} + +func TestParseResourceFromPath(t *testing.T) { + tests := []struct { + path string + wantType string + wantID int64 + }{ + {"/api/admin/channels", "channel", 0}, + {"/api/admin/channels/123", "channel", 123}, + {"/api/tenant/users/5", "user", 5}, + {"/api/admin/models", "model", 0}, + {"/api/admin/orders/42", "order", 42}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + gotType, gotID := parseResourceFromPath(tt.path) + if gotType != tt.wantType || gotID != tt.wantID { + t.Errorf("parseResourceFromPath(%q) = (%q,%d), want (%q,%d)", + tt.path, gotType, gotID, tt.wantType, tt.wantID) + } + }) + } +} + +func TestBuildAction(t *testing.T) { + tests := []struct { + name string + method string + path string + resourceType string + want string + }{ + {"create", "POST", "/api/admin/channels", "channel", "create_channel"}, + {"update", "PUT", "/api/admin/channels/123", "channel", "update_channel"}, + {"patch is update", "PATCH", "/api/admin/channels/123", "channel", "update_channel"}, + {"delete", "DELETE", "/api/admin/channels/123", "channel", "delete_channel"}, + {"custom action on resource", "POST", "/api/admin/channels/123/test", "channel", "test_channel"}, + {"no resource type falls back to method", "GET", "/api/admin/dashboard", "", "get"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildAction(tt.method, tt.path, tt.resourceType); got != tt.want { + t.Errorf("buildAction(%q,%q,%q) = %q, want %q", + tt.method, tt.path, tt.resourceType, got, tt.want) + } + }) + } +} + +func TestMaskSensitiveFields(t *testing.T) { + t.Run("masks top-level sensitive fields", func(t *testing.T) { + out := maskSensitiveFields(`{"password":"secret","name":"bob"}`) + var m map[string]any + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("output not valid JSON: %v", err) + } + if m["password"] != "******" { + t.Errorf("password not masked: %v", m["password"]) + } + if m["name"] != "bob" { + t.Errorf("non-sensitive field altered: %v", m["name"]) + } + }) + + t.Run("masks nested sensitive fields", func(t *testing.T) { + out := maskSensitiveFields(`{"user":{"api_key":"k","email":"a@b.c"}}`) + var m map[string]any + _ = json.Unmarshal([]byte(out), &m) + user := m["user"].(map[string]any) + if user["api_key"] != "******" { + t.Errorf("nested api_key not masked: %v", user["api_key"]) + } + if user["email"] != "a@b.c" { + t.Errorf("nested non-sensitive field altered: %v", user["email"]) + } + }) + + t.Run("case-insensitive key match", func(t *testing.T) { + out := maskSensitiveFields(`{"Password":"x","Refresh_Token":"y"}`) + var m map[string]any + _ = json.Unmarshal([]byte(out), &m) + if m["Password"] != "******" || m["Refresh_Token"] != "******" { + t.Errorf("case-insensitive masking failed: %v", m) + } + }) + + t.Run("non-JSON returned as-is", func(t *testing.T) { + if got := maskSensitiveFields("not json"); got != "not json" { + t.Errorf("non-JSON should pass through, got %q", got) + } + }) + + t.Run("empty string", func(t *testing.T) { + if got := maskSensitiveFields(""); got != "" { + t.Errorf("empty input should return empty, got %q", got) + } + }) +} + +func TestIsSensitive(t *testing.T) { + sensitive := []string{"password", "PASSWORD", "api_key", "Secret", "refresh_token", "encrypted_key"} + for _, f := range sensitive { + if !isSensitive(f) { + t.Errorf("%q should be sensitive", f) + } + } + for _, f := range []string{"name", "email", "id", "username"} { + if isSensitive(f) { + t.Errorf("%q should not be sensitive", f) + } + } +} + +func TestParseMaintenanceDuration(t *testing.T) { + ctx := context.Background() + tests := []struct { + in string + want int + }{ + {"", 300}, + {"invalid", 300}, + {"0s", 300}, + {"-5s", 300}, + {"30s", 30}, + {"2m", 120}, + {"1h", 3600}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := parseMaintenanceDuration(ctx, tt.in); got != tt.want { + t.Errorf("parseMaintenanceDuration(%q) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/utility/crypto/crypto_test.go b/internal/utility/crypto/crypto_test.go new file mode 100644 index 0000000..dca58ab --- /dev/null +++ b/internal/utility/crypto/crypto_test.go @@ -0,0 +1,157 @@ +package crypto + +import ( + "bytes" + "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) + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + t.Fatal(err) + } + // 翻转最后一个字节(密文/认证标签部分) + raw[len(raw)-1] ^= 0xFF + tampered := base64.StdEncoding.EncodeToString(raw) + + if _, err := DecryptString(key, tampered); err == nil { + t.Error("tampered ciphertext must fail authentication, got nil error") + } +} + +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() +} diff --git a/internal/utility/totp/totp_test.go b/internal/utility/totp/totp_test.go new file mode 100644 index 0000000..2c791cd --- /dev/null +++ b/internal/utility/totp/totp_test.go @@ -0,0 +1,90 @@ +package totp + +import ( + "encoding/hex" + "strings" + "testing" + "time" + + lib "github.com/pquerna/otp/totp" +) + +func TestGenerateSecret(t *testing.T) { + secret, uri, err := GenerateSecret("alice@example.com") + if err != nil { + t.Fatalf("GenerateSecret error: %v", err) + } + if secret == "" { + t.Error("secret is empty") + } + if !strings.HasPrefix(uri, "otpauth://totp/") { + t.Errorf("uri should be an otpauth TOTP URI, got %q", uri) + } + if !strings.Contains(uri, "Team-API") { + t.Errorf("uri should embed issuer, got %q", uri) + } +} + +func TestValidateCode(t *testing.T) { + secret, _, err := GenerateSecret("bob@example.com") + if err != nil { + t.Fatal(err) + } + + // 用同一算法为当前时间生成正确验证码,应通过 + valid, err := lib.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatal(err) + } + if !ValidateCode(valid, secret) { + t.Errorf("freshly generated code %q should validate", valid) + } + + // 明显非法的验证码应被拒绝(非数字 / 长度错误) + for _, bad := range []string{"", "abcdef", "12", "1234567"} { + if ValidateCode(bad, secret) { + t.Errorf("invalid code %q should not validate", bad) + } + } + + // 错误 secret 下,正确格式的码也应失败 + otherSecret, _, _ := GenerateSecret("carol@example.com") + if ValidateCode(valid, otherSecret) && otherSecret != secret { + t.Error("code generated for one secret should not validate against another") + } +} + +func TestGenerateBackupCodes(t *testing.T) { + const count = 10 + codes, err := GenerateBackupCodes(count) + if err != nil { + t.Fatalf("GenerateBackupCodes error: %v", err) + } + if len(codes) != count { + t.Fatalf("got %d codes, want %d", len(codes), count) + } + + seen := make(map[string]bool) + for _, c := range codes { + if len(c) != 12 { // 6 bytes -> 12 hex chars + t.Errorf("code %q length = %d, want 12 hex chars", c, len(c)) + } + if _, err := hex.DecodeString(c); err != nil { + t.Errorf("code %q is not valid hex: %v", c, err) + } + if seen[c] { + t.Errorf("duplicate backup code: %q", c) + } + seen[c] = true + } +} + +func TestGenerateBackupCodes_Zero(t *testing.T) { + codes, err := GenerateBackupCodes(0) + if err != nil { + t.Fatalf("error: %v", err) + } + if len(codes) != 0 { + t.Errorf("expected empty slice, got %d", len(codes)) + } +} diff --git a/relay/channel/openai/converter_test.go b/relay/channel/openai/converter_test.go new file mode 100644 index 0000000..5849ed7 --- /dev/null +++ b/relay/channel/openai/converter_test.go @@ -0,0 +1,201 @@ +package openai + +import ( + "encoding/json" + "testing" + + "github.com/qianfree/team-api/relay/dto" +) + +func TestExtractClaudeSystemText(t *testing.T) { + t.Run("plain string", func(t *testing.T) { + if got := extractClaudeSystemText("you are helpful"); got != "you are helpful" { + t.Errorf("got %q", got) + } + }) + t.Run("array of text blocks joined", func(t *testing.T) { + sys := []any{ + map[string]any{"type": "text", "text": "line1"}, + map[string]any{"type": "text", "text": "line2"}, + } + if got := extractClaudeSystemText(sys); got != "line1\nline2" { + t.Errorf("got %q, want \"line1\\nline2\"", got) + } + }) + t.Run("non-text blocks ignored", func(t *testing.T) { + sys := []any{ + map[string]any{"type": "text", "text": "keep"}, + map[string]any{"type": "image", "text": "drop"}, + } + if got := extractClaudeSystemText(sys); got != "keep" { + t.Errorf("got %q, want \"keep\"", got) + } + }) + t.Run("unknown type returns empty", func(t *testing.T) { + if got := extractClaudeSystemText(42); got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} + +func TestC2oJoinParts(t *testing.T) { + tests := []struct { + in []string + want string + }{ + {nil, ""}, + {[]string{}, ""}, + {[]string{"only"}, "only"}, + {[]string{"a", "b", "c"}, "a\nb\nc"}, + } + for _, tt := range tests { + if got := c2oJoinParts(tt.in); got != tt.want { + t.Errorf("c2oJoinParts(%v) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestC2oConvertThinkingToReasoningEffort(t *testing.T) { + tests := []struct { + name string + budget *int + want string + }{ + {"nil budget defaults medium", nil, "medium"}, + {"low boundary", intPtr(2048), "low"}, + {"low", intPtr(1000), "low"}, + {"medium boundary", intPtr(16384), "medium"}, + {"medium", intPtr(8000), "medium"}, + {"high", intPtr(16385), "high"}, + {"high large", intPtr(50000), "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c2oConvertThinkingToReasoningEffort(&dto.ClaudeThinking{BudgetTokens: tt.budget}) + if got != tt.want { + t.Errorf("budget=%v => %q, want %q", tt.budget, got, tt.want) + } + }) + } +} + +func TestG2oConvertThinkingConfig(t *testing.T) { + // 与 Claude 同阈值 + tests := []struct { + budget *int + want string + }{ + {nil, "medium"}, + {intPtr(2048), "low"}, + {intPtr(16384), "medium"}, + {intPtr(20000), "high"}, + } + for _, tt := range tests { + got := g2oConvertThinkingConfig(&dto.GeminiThinkingConfig{ThoughtBudget: tt.budget}) + if got != tt.want { + t.Errorf("budget=%v => %q, want %q", tt.budget, got, tt.want) + } + } +} + +func TestC2oConvertToolChoice(t *testing.T) { + t.Run("nil", func(t *testing.T) { + if got := c2oConvertToolChoice(nil); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + t.Run("string passthrough", func(t *testing.T) { + if got := c2oConvertToolChoice("auto"); got != "auto" { + t.Errorf("got %v", got) + } + }) + t.Run("type mappings", func(t *testing.T) { + cases := map[string]string{"auto": "auto", "any": "required", "none": "none"} + for in, want := range cases { + got := c2oConvertToolChoice(map[string]any{"type": in}) + if got != want { + t.Errorf("type=%q => %v, want %q", in, got, want) + } + } + }) + t.Run("specific tool maps to function", func(t *testing.T) { + got := c2oConvertToolChoice(map[string]any{"type": "tool", "name": "get_weather"}) + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", got) + } + if m["type"] != "function" { + t.Errorf("type = %v, want function", m["type"]) + } + fn, ok := m["function"].(map[string]any) + if !ok || fn["name"] != "get_weather" { + t.Errorf("function = %v, want name=get_weather", m["function"]) + } + }) + t.Run("tool without name falls back to required", func(t *testing.T) { + if got := c2oConvertToolChoice(map[string]any{"type": "tool"}); got != "required" { + t.Errorf("got %v, want required", got) + } + }) +} + +func TestG2oMapRole(t *testing.T) { + tests := map[string]string{ + "model": "assistant", + "user": "user", + "tool": "tool", + } + for in, want := range tests { + if got := g2oMapRole(in); got != want { + t.Errorf("g2oMapRole(%q) = %q, want %q", in, got, want) + } + } +} + +func TestC2rContentToString(t *testing.T) { + if got := c2rContentToString(nil); got != "" { + t.Errorf("nil => %q, want empty", got) + } + if got := c2rContentToString("hello"); got != "hello" { + t.Errorf("string => %q", got) + } + // 非字符串 -> JSON 编码 + got := c2rContentToString([]any{map[string]any{"type": "text", "text": "x"}}) + var back []any + if err := json.Unmarshal([]byte(got), &back); err != nil { + t.Errorf("non-string content should be JSON-encoded, got %q (%v)", got, err) + } +} + +func TestC2rGetMaxTokens(t *testing.T) { + tests := []struct { + name string + maxTok *int + maxComp *int + want int + }{ + {"both nil", nil, nil, 0}, + {"max_tokens only", intPtr(100), nil, 100}, + {"max_completion larger wins", intPtr(100), intPtr(200), 200}, + {"max_completion smaller keeps max_tokens", intPtr(300), intPtr(50), 300}, + {"zero max_tokens ignored", intPtr(0), nil, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := dto.GeneralOpenAIRequest{MaxTokens: tt.maxTok, MaxCompletionTokens: tt.maxComp} + if got := c2rGetMaxTokens(req); got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} + +func TestConvertClaudeUserMessage_StringContent(t *testing.T) { + msgs := convertClaudeUserMessage(dto.ClaudeMessage{Role: "user", Content: "hi there"}) + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].Role != "user" || msgs[0].Content != "hi there" { + t.Errorf("got %+v", msgs[0]) + } +}