- 记录失败请求并在用户端/管理端展示;分类下拉改用统一 Select 组件 - 模型过滤改后端 ILIKE 模糊匹配;新增「Key 名称」列(含已删除标记)与按 Key 过滤;时间列移至末列 - 用户可见「已删除 key 失败请求」:OpsErrorLogFilter 加 MatchDeletedKeyOwner,用户侧归属 放宽为 (user_id OR deleted_key_owner_user_id),让 key 原所有者能看到删除 key 后继续请求 导致的认证失败记录(他人仍 NotFound,不泄露存在性) - 迁移 148:ops_error_logs 用户+时间索引 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
5.3 KiB
Go
168 lines
5.3 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMapUserErrorCategory(t *testing.T) {
|
|
cases := []struct {
|
|
phase, etype, want string
|
|
}{
|
|
{"auth", "authentication_error", "auth"},
|
|
{"request", "rate_limit_error", "rate_limit"},
|
|
{"request", "billing_error", "quota"},
|
|
{"request", "subscription_error", "quota"},
|
|
{"request", "invalid_request_error", "invalid_request"},
|
|
{"routing", "api_error", "service_unavailable"},
|
|
{"upstream", "upstream_error", "upstream"},
|
|
{"network", "api_error", "upstream"},
|
|
{"internal", "api_error", "internal"},
|
|
{"weird", "weird", "other"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := MapUserErrorCategory(c.phase, c.etype); got != c.want {
|
|
t.Errorf("MapUserErrorCategory(%q,%q)=%q want %q", c.phase, c.etype, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCategoryToFilter(t *testing.T) {
|
|
phases, types := CategoryToFilter("rate_limit")
|
|
if len(types) != 1 || types[0] != "rate_limit_error" || len(phases) != 0 {
|
|
t.Fatalf("rate_limit => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("auth")
|
|
if len(phases) != 1 || phases[0] != "auth" || len(types) != 0 {
|
|
t.Fatalf("auth => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("service_unavailable")
|
|
if len(phases) != 1 || phases[0] != "routing" || len(types) != 0 {
|
|
t.Fatalf("service_unavailable => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("upstream")
|
|
if len(phases) != 2 || phases[0] != "upstream" || phases[1] != "network" || len(types) != 0 {
|
|
t.Fatalf("upstream => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("internal")
|
|
if len(phases) != 1 || phases[0] != "internal" || len(types) != 0 {
|
|
t.Fatalf("internal => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("quota")
|
|
if len(types) != 2 || types[0] != "billing_error" || types[1] != "subscription_error" || len(phases) != 0 {
|
|
t.Fatalf("quota => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("invalid_request")
|
|
if len(types) != 1 || types[0] != "invalid_request_error" || len(phases) != 0 {
|
|
t.Fatalf("invalid_request => phases=%v types=%v", phases, types)
|
|
}
|
|
phases, types = CategoryToFilter("other")
|
|
if len(phases) != 0 || len(types) != 0 {
|
|
t.Fatalf("other => phases=%v types=%v", phases, types)
|
|
}
|
|
}
|
|
|
|
func TestToUserErrorRequest_RedactsSensitiveFields(t *testing.T) {
|
|
src := &OpsErrorLog{
|
|
ID: 123,
|
|
CreatedAt: time.Unix(0, 0).UTC(),
|
|
Model: "m",
|
|
RequestedModel: "rm",
|
|
InboundEndpoint: "/v1/chat/completions",
|
|
StatusCode: 429,
|
|
Platform: "openai",
|
|
Phase: "request",
|
|
Type: "rate_limit_error",
|
|
Message: "rate limit exceeded",
|
|
APIKeyName: "my-key",
|
|
APIKeyDeleted: true,
|
|
}
|
|
out := ToUserErrorRequest(src)
|
|
if out.ID != 123 {
|
|
t.Errorf("want ID=123, got %d", out.ID)
|
|
}
|
|
if out.Model != "rm" {
|
|
t.Errorf("want requested_model preferred, got %q", out.Model)
|
|
}
|
|
if out.Category != "rate_limit" {
|
|
t.Errorf("category=%q", out.Category)
|
|
}
|
|
if out.StatusCode != 429 || out.InboundEndpoint != "/v1/chat/completions" || out.Platform != "openai" {
|
|
t.Errorf("basic fields wrong: %+v", out)
|
|
}
|
|
if out.Message != "rate limit exceeded" {
|
|
t.Errorf("want message=%q, got %q", "rate limit exceeded", out.Message)
|
|
}
|
|
if out.KeyName != "my-key" {
|
|
t.Errorf("want key_name=my-key, got %q", out.KeyName)
|
|
}
|
|
if !out.KeyDeleted {
|
|
t.Error("want key_deleted=true")
|
|
}
|
|
}
|
|
|
|
func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
|
|
uid := int64(42)
|
|
upstreamStatus := 503
|
|
src := &OpsErrorLogDetail{
|
|
OpsErrorLog: OpsErrorLog{
|
|
ID: 999,
|
|
CreatedAt: time.Unix(1000, 0).UTC(),
|
|
Model: "gpt-4",
|
|
RequestedModel: "gpt-4-turbo",
|
|
InboundEndpoint: "/v1/chat/completions",
|
|
StatusCode: 502,
|
|
Platform: "openai",
|
|
Phase: "upstream",
|
|
Type: "api_error",
|
|
Message: "upstream error",
|
|
UserID: &uid,
|
|
UserEmail: "secret@example.com",
|
|
ClientIP: func() *string { s := "1.2.3.4"; return &s }(),
|
|
UpstreamEndpoint: "https://api.openai.com/v1/chat/completions",
|
|
},
|
|
ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`,
|
|
UserAgent: "Mozilla/5.0 secret-agent",
|
|
UpstreamStatusCode: &upstreamStatus,
|
|
}
|
|
|
|
out := ToUserErrorRequestDetail(src)
|
|
if out == nil {
|
|
t.Fatal("expected non-nil detail")
|
|
}
|
|
|
|
// 基础字段正确映射
|
|
if out.ID != 999 {
|
|
t.Errorf("want ID=999, got %d", out.ID)
|
|
}
|
|
if out.Message != "upstream error" {
|
|
t.Errorf("want message=%q, got %q", "upstream error", out.Message)
|
|
}
|
|
if out.ErrorBody != src.ErrorBody {
|
|
t.Errorf("ErrorBody mismatch")
|
|
}
|
|
if out.UpstreamStatusCode == nil || *out.UpstreamStatusCode != 503 {
|
|
t.Errorf("UpstreamStatusCode mismatch")
|
|
}
|
|
|
|
// 序列化后不含敏感字段
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal failed: %v", err)
|
|
}
|
|
raw := string(b)
|
|
for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} {
|
|
if strings.Contains(raw, forbidden) {
|
|
t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToUserErrorRequestDetail_Nil(t *testing.T) {
|
|
if out := ToUserErrorRequestDetail(nil); out != nil {
|
|
t.Errorf("expected nil for nil input, got %+v", out)
|
|
}
|
|
}
|