ops_service_user_error_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "testing"
  6. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  7. )
  8. type stubOpsRepoForUserErr struct {
  9. OpsRepository // 嵌入接口,未实现的方法 panic,仅覆盖 ListErrorLogs
  10. gotFilter *OpsErrorLogFilter
  11. // GetErrorLogByID 控制字段
  12. detailToReturn *OpsErrorLogDetail
  13. detailErrToReturn error
  14. }
  15. func (s *stubOpsRepoForUserErr) ListErrorLogs(ctx context.Context, f *OpsErrorLogFilter) (*OpsErrorLogList, error) {
  16. s.gotFilter = f
  17. return &OpsErrorLogList{
  18. Errors: []*OpsErrorLog{{
  19. Phase: "request", Type: "rate_limit_error",
  20. Model: "m", RequestedModel: "rm", StatusCode: 429,
  21. Message: "secret", UserEmail: "a@b.c",
  22. }},
  23. Total: 1, Page: 1, PageSize: 20,
  24. }, nil
  25. }
  26. func (s *stubOpsRepoForUserErr) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
  27. if s.detailErrToReturn != nil {
  28. return nil, s.detailErrToReturn
  29. }
  30. return s.detailToReturn, nil
  31. }
  32. func TestListUserErrorRequests_ForcesScopeAndRedacts(t *testing.T) {
  33. stub := &stubOpsRepoForUserErr{}
  34. svc := &OpsService{opsRepo: stub}
  35. uid := int64(42)
  36. kid := int64(7)
  37. in := &OpsErrorLogFilter{UserID: nil, View: "errors", Phase: "upstream", APIKeyID: &kid}
  38. out, err := svc.ListUserErrorRequests(context.Background(), uid, in)
  39. if err != nil {
  40. t.Fatal(err)
  41. }
  42. // 强制按用户
  43. if stub.gotFilter.UserID == nil || *stub.gotFilter.UserID != uid {
  44. t.Fatalf("UserID not forced: %+v", stub.gotFilter.UserID)
  45. }
  46. // 强制 View=all(含业务限流/余额)
  47. if stub.gotFilter.View != "all" {
  48. t.Fatalf("View not forced to all: %q", stub.gotFilter.View)
  49. }
  50. // 强制排除 count_tokens
  51. if !stub.gotFilter.ExcludeCountTokens {
  52. t.Fatal("ExcludeCountTokens not forced")
  53. }
  54. // 强制清空 Phase(防止 "upstream" 绕过 status>=400 子句 + 与 ErrorPhasesAny 双重约束)
  55. if stub.gotFilter.Phase != "" {
  56. t.Fatalf("Phase not cleared: %q", stub.gotFilter.Phase)
  57. }
  58. // APIKeyID 透传保留(用户可按自己 key 过滤;越权由 user_id AND api_key_id 双重防护)
  59. if stub.gotFilter.APIKeyID == nil || *stub.gotFilter.APIKeyID != kid {
  60. t.Fatalf("APIKeyID should be preserved, got %v", stub.gotFilter.APIKeyID)
  61. }
  62. // 调用方传入的 filter 不应被原地篡改(验证 shallow copy 隔离生效)
  63. if in.View != "errors" || in.UserID != nil || in.Phase != "upstream" {
  64. t.Fatalf("caller filter was mutated: View=%q UserID=%v Phase=%q", in.View, in.UserID, in.Phase)
  65. }
  66. // 脱敏:返回条目含 message 字段
  67. if len(out.Items) != 1 || out.Items[0].Category != "rate_limit" || out.Items[0].Model != "rm" {
  68. t.Fatalf("bad item: %+v", out.Items)
  69. }
  70. }
  71. func TestGetUserErrorRequestDetail_OwnershipEnforced(t *testing.T) {
  72. ownerUID := int64(999)
  73. callerUID := int64(1)
  74. upstreamStatus := 503
  75. detail := &OpsErrorLogDetail{
  76. OpsErrorLog: OpsErrorLog{
  77. ID: 42,
  78. Phase: "upstream",
  79. Type: "api_error",
  80. Model: "gpt-4",
  81. RequestedModel: "gpt-4-turbo",
  82. InboundEndpoint: "/v1/chat/completions",
  83. StatusCode: 502,
  84. Platform: "openai",
  85. Message: "upstream failed",
  86. UserID: &ownerUID,
  87. },
  88. ErrorBody: `{"error":"upstream"}`,
  89. UpstreamStatusCode: &upstreamStatus,
  90. }
  91. stub := &stubOpsRepoForUserErr{detailToReturn: detail}
  92. svc := &OpsService{opsRepo: stub}
  93. // 越权调用(callerUID=1,但记录属于 ownerUID=999)→ 应返回 NotFound,detail 为 nil
  94. got, err := svc.GetUserErrorRequestDetail(context.Background(), callerUID, 42)
  95. if err == nil {
  96. t.Fatal("expected error for unauthorized access, got nil")
  97. }
  98. if got != nil {
  99. t.Fatalf("expected nil detail for unauthorized access, got %+v", got)
  100. }
  101. // 验证错误为 NotFound(不暴露存在性)
  102. if !infraerrors.IsNotFound(err) {
  103. t.Fatalf("expected NotFound error, got: %v", err)
  104. }
  105. // 合法调用(callerUID=999 = ownerUID)→ 应返回 non-nil detail
  106. got2, err2 := svc.GetUserErrorRequestDetail(context.Background(), ownerUID, 42)
  107. if err2 != nil {
  108. t.Fatalf("expected no error for legitimate access, got %v", err2)
  109. }
  110. if got2 == nil {
  111. t.Fatal("expected non-nil detail for legitimate access")
  112. }
  113. if got2.ID != 42 {
  114. t.Errorf("want ID=42, got %d", got2.ID)
  115. }
  116. if got2.ErrorBody != `{"error":"upstream"}` {
  117. t.Errorf("want ErrorBody=%q, got %q", `{"error":"upstream"}`, got2.ErrorBody)
  118. }
  119. if got2.UpstreamStatusCode == nil || *got2.UpstreamStatusCode != 503 {
  120. t.Errorf("want UpstreamStatusCode=503, got %v", got2.UpstreamStatusCode)
  121. }
  122. if got2.Message != "upstream failed" {
  123. t.Errorf("want Message=%q, got %q", "upstream failed", got2.Message)
  124. }
  125. }
  126. func TestGetUserErrorRequestDetail_NotFound(t *testing.T) {
  127. stub := &stubOpsRepoForUserErr{detailErrToReturn: sql.ErrNoRows}
  128. svc := &OpsService{opsRepo: stub}
  129. got, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 999)
  130. if err == nil {
  131. t.Fatal("expected error for not found, got nil")
  132. }
  133. if got != nil {
  134. t.Fatalf("expected nil detail, got %+v", got)
  135. }
  136. }
  137. func TestGetUserErrorRequestDetail_InvalidID(t *testing.T) {
  138. stub := &stubOpsRepoForUserErr{}
  139. svc := &OpsService{opsRepo: stub}
  140. _, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 0)
  141. if err == nil {
  142. t.Fatal("expected error for id=0")
  143. }
  144. _, err = svc.GetUserErrorRequestDetail(context.Background(), 1, -5)
  145. if err == nil {
  146. t.Fatal("expected error for id=-5")
  147. }
  148. }