错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab) 对齐用量明细的交互与信息密度。Squash of: - feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列) - refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort - feat(usage): /admin/usage 错误请求新增分类过滤 - fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in - fix(ops): 错误列表用户列回退显示已删除 KEY 所有者 - fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
)
|
|
|
|
type stubOpsRepoForUserErr struct {
|
|
OpsRepository // 嵌入接口,未实现的方法 panic,仅覆盖 ListErrorLogs
|
|
gotFilter *OpsErrorLogFilter
|
|
|
|
// GetErrorLogByID 控制字段
|
|
detailToReturn *OpsErrorLogDetail
|
|
detailErrToReturn error
|
|
}
|
|
|
|
func (s *stubOpsRepoForUserErr) ListErrorLogs(ctx context.Context, f *OpsErrorLogFilter) (*OpsErrorLogList, error) {
|
|
s.gotFilter = f
|
|
return &OpsErrorLogList{
|
|
Errors: []*OpsErrorLog{{
|
|
Phase: "request", Type: "rate_limit_error",
|
|
Model: "m", RequestedModel: "rm", StatusCode: 429,
|
|
Message: "secret", UserEmail: "a@b.c",
|
|
}},
|
|
Total: 1, Page: 1, PageSize: 20,
|
|
}, nil
|
|
}
|
|
|
|
func (s *stubOpsRepoForUserErr) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
|
|
if s.detailErrToReturn != nil {
|
|
return nil, s.detailErrToReturn
|
|
}
|
|
return s.detailToReturn, nil
|
|
}
|
|
|
|
func TestListUserErrorRequests_ForcesScopeAndRedacts(t *testing.T) {
|
|
stub := &stubOpsRepoForUserErr{}
|
|
svc := &OpsService{opsRepo: stub}
|
|
uid := int64(42)
|
|
kid := int64(7)
|
|
in := &OpsErrorLogFilter{UserID: nil, View: "errors", Phase: "upstream", APIKeyID: &kid}
|
|
out, err := svc.ListUserErrorRequests(context.Background(), uid, in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 强制按用户
|
|
if stub.gotFilter.UserID == nil || *stub.gotFilter.UserID != uid {
|
|
t.Fatalf("UserID not forced: %+v", stub.gotFilter.UserID)
|
|
}
|
|
// 强制 View=all(含业务限流/余额)
|
|
if stub.gotFilter.View != "all" {
|
|
t.Fatalf("View not forced to all: %q", stub.gotFilter.View)
|
|
}
|
|
// 强制排除 count_tokens
|
|
if !stub.gotFilter.ExcludeCountTokens {
|
|
t.Fatal("ExcludeCountTokens not forced")
|
|
}
|
|
// 强制清空 Phase(防止 "upstream" 绕过 status>=400 子句 + 与 ErrorPhasesAny 双重约束)
|
|
if stub.gotFilter.Phase != "" {
|
|
t.Fatalf("Phase not cleared: %q", stub.gotFilter.Phase)
|
|
}
|
|
// APIKeyID 透传保留(用户可按自己 key 过滤;越权由 user_id AND api_key_id 双重防护)
|
|
if stub.gotFilter.APIKeyID == nil || *stub.gotFilter.APIKeyID != kid {
|
|
t.Fatalf("APIKeyID should be preserved, got %v", stub.gotFilter.APIKeyID)
|
|
}
|
|
// 调用方传入的 filter 不应被原地篡改(验证 shallow copy 隔离生效)
|
|
if in.View != "errors" || in.UserID != nil || in.Phase != "upstream" {
|
|
t.Fatalf("caller filter was mutated: View=%q UserID=%v Phase=%q", in.View, in.UserID, in.Phase)
|
|
}
|
|
// 脱敏:返回条目含 message 字段
|
|
if len(out.Items) != 1 || out.Items[0].Category != "rate_limit" || out.Items[0].Model != "rm" {
|
|
t.Fatalf("bad item: %+v", out.Items)
|
|
}
|
|
}
|
|
|
|
func TestGetUserErrorRequestDetail_OwnershipEnforced(t *testing.T) {
|
|
ownerUID := int64(999)
|
|
callerUID := int64(1)
|
|
upstreamStatus := 503
|
|
|
|
detail := &OpsErrorLogDetail{
|
|
OpsErrorLog: OpsErrorLog{
|
|
ID: 42,
|
|
Phase: "upstream",
|
|
Type: "api_error",
|
|
Model: "gpt-4",
|
|
RequestedModel: "gpt-4-turbo",
|
|
InboundEndpoint: "/v1/chat/completions",
|
|
StatusCode: 502,
|
|
Platform: "openai",
|
|
Message: "upstream failed",
|
|
UserID: &ownerUID,
|
|
},
|
|
ErrorBody: `{"error":"upstream"}`,
|
|
UpstreamStatusCode: &upstreamStatus,
|
|
}
|
|
|
|
stub := &stubOpsRepoForUserErr{detailToReturn: detail}
|
|
svc := &OpsService{opsRepo: stub}
|
|
|
|
// 越权调用(callerUID=1,但记录属于 ownerUID=999)→ 应返回 NotFound,detail 为 nil
|
|
got, err := svc.GetUserErrorRequestDetail(context.Background(), callerUID, 42)
|
|
if err == nil {
|
|
t.Fatal("expected error for unauthorized access, got nil")
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("expected nil detail for unauthorized access, got %+v", got)
|
|
}
|
|
// 验证错误为 NotFound(不暴露存在性)
|
|
if !infraerrors.IsNotFound(err) {
|
|
t.Fatalf("expected NotFound error, got: %v", err)
|
|
}
|
|
|
|
// 合法调用(callerUID=999 = ownerUID)→ 应返回 non-nil detail
|
|
got2, err2 := svc.GetUserErrorRequestDetail(context.Background(), ownerUID, 42)
|
|
if err2 != nil {
|
|
t.Fatalf("expected no error for legitimate access, got %v", err2)
|
|
}
|
|
if got2 == nil {
|
|
t.Fatal("expected non-nil detail for legitimate access")
|
|
}
|
|
if got2.ID != 42 {
|
|
t.Errorf("want ID=42, got %d", got2.ID)
|
|
}
|
|
if got2.ErrorBody != `{"error":"upstream"}` {
|
|
t.Errorf("want ErrorBody=%q, got %q", `{"error":"upstream"}`, got2.ErrorBody)
|
|
}
|
|
if got2.UpstreamStatusCode == nil || *got2.UpstreamStatusCode != 503 {
|
|
t.Errorf("want UpstreamStatusCode=503, got %v", got2.UpstreamStatusCode)
|
|
}
|
|
if got2.Message != "upstream failed" {
|
|
t.Errorf("want Message=%q, got %q", "upstream failed", got2.Message)
|
|
}
|
|
}
|
|
|
|
func TestGetUserErrorRequestDetail_NotFound(t *testing.T) {
|
|
stub := &stubOpsRepoForUserErr{detailErrToReturn: sql.ErrNoRows}
|
|
svc := &OpsService{opsRepo: stub}
|
|
|
|
got, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 999)
|
|
if err == nil {
|
|
t.Fatal("expected error for not found, got nil")
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("expected nil detail, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGetUserErrorRequestDetail_InvalidID(t *testing.T) {
|
|
stub := &stubOpsRepoForUserErr{}
|
|
svc := &OpsService{opsRepo: stub}
|
|
|
|
_, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 0)
|
|
if err == nil {
|
|
t.Fatal("expected error for id=0")
|
|
}
|
|
_, err = svc.GetUserErrorRequestDetail(context.Background(), 1, -5)
|
|
if err == nil {
|
|
t.Fatal("expected error for id=-5")
|
|
}
|
|
}
|
|
|
|
func TestListUserErrorRequests_EnablesMatchDeletedKeyOwner(t *testing.T) {
|
|
stub := &stubOpsRepoForUserErr{}
|
|
svc := &OpsService{opsRepo: stub}
|
|
uid := int64(42)
|
|
|
|
if _, err := svc.ListUserErrorRequests(context.Background(), uid, &OpsErrorLogFilter{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stub.gotFilter == nil || !stub.gotFilter.MatchDeletedKeyOwner {
|
|
t.Fatal("ListUserErrorRequests should enable MatchDeletedKeyOwner for the user scope")
|
|
}
|
|
}
|
|
|
|
func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) {
|
|
ownerUID := int64(777)
|
|
otherUID := int64(2)
|
|
|
|
// 情况2:user_id=NULL,靠 deleted_key_owner_user_id 归因到 ownerUID
|
|
mk := func() *OpsErrorLogDetail {
|
|
return &OpsErrorLogDetail{
|
|
OpsErrorLog: OpsErrorLog{
|
|
ID: 55,
|
|
Phase: "auth",
|
|
Type: "api_error",
|
|
StatusCode: 401,
|
|
Message: "Invalid API key",
|
|
UserID: nil,
|
|
APIKeyName: "my-old-key",
|
|
APIKeyDeleted: true,
|
|
DeletedKeyOwnerUserID: &ownerUID,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 原所有者(经 deleted_key 归因)→ 放行
|
|
svcOwner := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}}
|
|
got, err := svcOwner.GetUserErrorRequestDetail(context.Background(), ownerUID, 55)
|
|
if err != nil {
|
|
t.Fatalf("owner via deleted_key should be allowed, got err: %v", err)
|
|
}
|
|
if got == nil || got.ID != 55 {
|
|
t.Fatalf("expected detail ID=55, got %+v", got)
|
|
}
|
|
if !got.KeyDeleted || got.KeyName != "my-old-key" {
|
|
t.Fatalf("expected KeyDeleted=true KeyName=my-old-key, got %+v", got)
|
|
}
|
|
|
|
// 他人 → NotFound,不泄露存在性
|
|
svcOther := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}}
|
|
got2, err2 := svcOther.GetUserErrorRequestDetail(context.Background(), otherUID, 55)
|
|
if err2 == nil || got2 != nil {
|
|
t.Fatalf("non-owner should get (nil, NotFound), got detail=%+v err=%v", got2, err2)
|
|
}
|
|
if !infraerrors.IsNotFound(err2) {
|
|
t.Fatalf("expected NotFound, got %v", err2)
|
|
}
|
|
}
|