content_moderation_proxy_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strconv"
  9. "strings"
  10. "sync/atomic"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  14. )
  15. // contentModerationTestProxyRepo 仅实现审计代理路径用到的 GetByID,其余方法不应被调用。
  16. type contentModerationTestProxyRepo struct {
  17. proxies map[int64]*Proxy
  18. getByIDErr error
  19. getCalls atomic.Int64
  20. }
  21. func (r *contentModerationTestProxyRepo) GetByID(ctx context.Context, id int64) (*Proxy, error) {
  22. r.getCalls.Add(1)
  23. if r.getByIDErr != nil {
  24. return nil, r.getByIDErr
  25. }
  26. if px, ok := r.proxies[id]; ok {
  27. return px, nil
  28. }
  29. return nil, errors.New("proxy not found")
  30. }
  31. func (r *contentModerationTestProxyRepo) CountGroupsByProxyID(ctx context.Context, id int64) (int64, error) {
  32. return 0, nil
  33. }
  34. func (r *contentModerationTestProxyRepo) Create(ctx context.Context, proxy *Proxy) error {
  35. panic("not implemented")
  36. }
  37. func (r *contentModerationTestProxyRepo) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
  38. panic("not implemented")
  39. }
  40. func (r *contentModerationTestProxyRepo) Update(ctx context.Context, proxy *Proxy) error {
  41. panic("not implemented")
  42. }
  43. func (r *contentModerationTestProxyRepo) Delete(ctx context.Context, id int64) error {
  44. panic("not implemented")
  45. }
  46. func (r *contentModerationTestProxyRepo) List(ctx context.Context, params pagination.PaginationParams) ([]Proxy, *pagination.PaginationResult, error) {
  47. panic("not implemented")
  48. }
  49. func (r *contentModerationTestProxyRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) {
  50. panic("not implemented")
  51. }
  52. func (r *contentModerationTestProxyRepo) ListWithFiltersAndAccountCount(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]ProxyWithAccountCount, *pagination.PaginationResult, error) {
  53. panic("not implemented")
  54. }
  55. func (r *contentModerationTestProxyRepo) ListActive(ctx context.Context) ([]Proxy, error) {
  56. panic("not implemented")
  57. }
  58. func (r *contentModerationTestProxyRepo) ListActiveWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
  59. panic("not implemented")
  60. }
  61. func (r *contentModerationTestProxyRepo) ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) {
  62. panic("not implemented")
  63. }
  64. func (r *contentModerationTestProxyRepo) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
  65. panic("not implemented")
  66. }
  67. func (r *contentModerationTestProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
  68. panic("not implemented")
  69. }
  70. func (r *contentModerationTestProxyRepo) SweepExpiredProxies(ctx context.Context, now time.Time) (int64, error) {
  71. panic("not implemented")
  72. }
  73. func (r *contentModerationTestProxyRepo) ListAllForFallback(ctx context.Context) ([]Proxy, error) {
  74. panic("not implemented")
  75. }
  76. func (r *contentModerationTestProxyRepo) CountExpired(ctx context.Context) (int64, error) {
  77. panic("not implemented")
  78. }
  79. func (r *contentModerationTestProxyRepo) CountExpiringSoon(ctx context.Context, now time.Time) (int64, error) {
  80. panic("not implemented")
  81. }
  82. func moderationProxyIDPtr(v int64) *int64 { return &v }
  83. // 审计请求必须真正经过配置的代理发出(#2646 核心行为)。
  84. // 通过一个本地 HTTP 正向代理验证:BaseURL 指向不可直连的假域名,
  85. // 请求只有走代理才能得到响应。
  86. func TestContentModerationCallRoutesThroughProxy(t *testing.T) {
  87. var proxied atomic.Int64
  88. proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  89. // HTTP 目标经正向代理时,代理收到的是绝对 URI 请求。
  90. if !strings.HasPrefix(r.RequestURI, "http://moderation-proxy-test.invalid") {
  91. t.Errorf("expected absolute-URI proxy request, got %q", r.RequestURI)
  92. }
  93. proxied.Add(1)
  94. w.Header().Set("Content-Type", "application/json")
  95. _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
  96. }))
  97. defer proxySrv.Close()
  98. proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://")
  99. host, portStr, ok := strings.Cut(proxyAddr, ":")
  100. if !ok {
  101. t.Fatalf("unexpected proxy addr: %s", proxyAddr)
  102. }
  103. port, err := strconv.Atoi(portStr)
  104. if err != nil {
  105. t.Fatalf("parse proxy port: %v", err)
  106. }
  107. proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
  108. 7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive},
  109. }}
  110. svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
  111. cfg := defaultContentModerationConfig()
  112. cfg.BaseURL = "http://moderation-proxy-test.invalid"
  113. cfg.ProxyID = moderationProxyIDPtr(7)
  114. cfg.normalize()
  115. httpStatus := 0
  116. if _, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus); err != nil {
  117. t.Fatalf("expected moderation call via proxy to succeed, got: %v", err)
  118. }
  119. if proxied.Load() == 0 {
  120. t.Fatal("expected request to be routed through the proxy server")
  121. }
  122. }
  123. // 代理解析失败必须报错,而不是静默回退直连。
  124. func TestContentModerationProxyResolveFailureDoesNotFallBackToDirect(t *testing.T) {
  125. var direct atomic.Int64
  126. directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  127. direct.Add(1)
  128. w.Header().Set("Content-Type", "application/json")
  129. _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
  130. }))
  131. defer directSrv.Close()
  132. proxyRepo := &contentModerationTestProxyRepo{getByIDErr: errors.New("proxy deleted")}
  133. svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
  134. cfg := defaultContentModerationConfig()
  135. cfg.BaseURL = directSrv.URL
  136. cfg.ProxyID = moderationProxyIDPtr(9)
  137. cfg.normalize()
  138. httpStatus := 0
  139. _, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus)
  140. if err == nil || !strings.Contains(err.Error(), "resolve moderation proxy") {
  141. t.Fatalf("expected proxy resolve error, got: %v", err)
  142. }
  143. if direct.Load() != 0 {
  144. t.Fatal("must not fall back to direct connection when proxy resolution fails")
  145. }
  146. }
  147. // 代理 URL 解析结果按 TTL 缓存,热路径不应每次调用都查库。
  148. func TestContentModerationProxyURLResolutionCached(t *testing.T) {
  149. proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
  150. 3: {ID: 3, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive},
  151. }}
  152. svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
  153. for i := 0; i < 5; i++ {
  154. if _, err := svc.resolveModerationProxyURL(context.Background(), 3); err != nil {
  155. t.Fatalf("resolve attempt %d failed: %v", i, err)
  156. }
  157. }
  158. if got := proxyRepo.getCalls.Load(); got != 1 {
  159. t.Fatalf("expected exactly 1 repository lookup thanks to caching, got %d", got)
  160. }
  161. }
  162. // UpdateConfig 的 proxy_id 语义:>0 设置、nil 保持、<=0 清除;配置视图回显。
  163. func TestContentModerationUpdateConfigProxyIDSemantics(t *testing.T) {
  164. settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}}
  165. proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
  166. 5: {ID: 5, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive},
  167. }}
  168. svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil)
  169. ctx := context.Background()
  170. view, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(5)})
  171. if err != nil {
  172. t.Fatalf("set proxy_id=5: %v", err)
  173. }
  174. if view.ProxyID == nil || *view.ProxyID != 5 {
  175. t.Fatalf("expected proxy_id=5 in view, got %v", view.ProxyID)
  176. }
  177. // nil 表示不修改,代理保持不变。
  178. enabled := true
  179. view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{Enabled: &enabled})
  180. if err != nil {
  181. t.Fatalf("update unrelated field: %v", err)
  182. }
  183. if view.ProxyID == nil || *view.ProxyID != 5 {
  184. t.Fatalf("expected proxy_id to stay 5 when omitted, got %v", view.ProxyID)
  185. }
  186. // 0 表示清除,恢复直连。
  187. view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(0)})
  188. if err != nil {
  189. t.Fatalf("clear proxy_id: %v", err)
  190. }
  191. if view.ProxyID != nil {
  192. t.Fatalf("expected proxy_id cleared, got %v", *view.ProxyID)
  193. }
  194. // 不存在的代理必须被校验拒绝。
  195. if _, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(404)}); err == nil {
  196. t.Fatal("expected validation error for nonexistent proxy")
  197. }
  198. }
  199. // TestAPIKeys 的 proxy_id 语义:nil 沿用已保存配置的代理;0 强制直连;>0 指定代理。
  200. func TestContentModerationTestAPIKeysProxySemantics(t *testing.T) {
  201. var proxied atomic.Int64
  202. proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  203. proxied.Add(1)
  204. w.Header().Set("Content-Type", "application/json")
  205. _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
  206. }))
  207. defer proxySrv.Close()
  208. proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://")
  209. host, portStr, _ := strings.Cut(proxyAddr, ":")
  210. port, _ := strconv.Atoi(portStr)
  211. savedCfg := defaultContentModerationConfig()
  212. savedCfg.BaseURL = "http://moderation-proxy-test.invalid"
  213. savedCfg.ProxyID = moderationProxyIDPtr(7)
  214. savedCfg.APIKeys = []string{"sk-saved"}
  215. rawCfg, err := json.Marshal(savedCfg)
  216. if err != nil {
  217. t.Fatalf("marshal cfg: %v", err)
  218. }
  219. settingRepo := &contentModerationTestSettingRepo{values: map[string]string{
  220. SettingKeyContentModerationConfig: string(rawCfg),
  221. }}
  222. proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
  223. 7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive},
  224. }}
  225. svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil)
  226. // nil:沿用已保存配置的代理,测试请求应经过代理成功。
  227. result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{APIKeys: []string{"sk-input"}})
  228. if err != nil {
  229. t.Fatalf("test with saved proxy: %v", err)
  230. }
  231. if len(result.Items) != 1 || result.Items[0].Status == "error" {
  232. t.Fatalf("expected key test via proxy to succeed, got %+v", result.Items)
  233. }
  234. if proxied.Load() == 0 {
  235. t.Fatal("expected test request to route through the saved proxy")
  236. }
  237. // 0:强制直连;BaseURL 指向本地可直连服务器,应成功且不再经过代理。
  238. var direct atomic.Int64
  239. directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  240. direct.Add(1)
  241. w.Header().Set("Content-Type", "application/json")
  242. _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
  243. }))
  244. defer directSrv.Close()
  245. before := proxied.Load()
  246. result, err = svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
  247. APIKeys: []string{"sk-input"},
  248. BaseURL: directSrv.URL,
  249. ProxyID: moderationProxyIDPtr(0),
  250. })
  251. if err != nil {
  252. t.Fatalf("test with forced direct: %v", err)
  253. }
  254. if len(result.Items) != 1 || result.Items[0].Status == "error" {
  255. t.Fatalf("expected forced-direct test to succeed, got %+v", result.Items)
  256. }
  257. if direct.Load() == 0 {
  258. t.Fatal("expected forced-direct test to reach the base URL directly")
  259. }
  260. if proxied.Load() != before {
  261. t.Fatal("forced-direct test must not route through the proxy")
  262. }
  263. }