ops_upstream_context_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package service
  2. import (
  3. "context"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "github.com/gin-gonic/gin"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func TestSafeUpstreamURL(t *testing.T) {
  11. tests := []struct {
  12. name string
  13. input string
  14. want string
  15. }{
  16. {"strips query", "https://api.anthropic.com/v1/messages?beta=true", "https://api.anthropic.com/v1/messages"},
  17. {"strips fragment", "https://api.openai.com/v1/responses#frag", "https://api.openai.com/v1/responses"},
  18. {"strips both", "https://host/path?token=secret#x", "https://host/path"},
  19. {"no query or fragment", "https://host/path", "https://host/path"},
  20. {"empty string", "", ""},
  21. {"whitespace only", " ", ""},
  22. {"query before fragment", "https://h/p?a=1#f", "https://h/p"},
  23. }
  24. for _, tt := range tests {
  25. t.Run(tt.name, func(t *testing.T) {
  26. require.Equal(t, tt.want, safeUpstreamURL(tt.input))
  27. })
  28. }
  29. }
  30. func TestOpsUpstreamErrorEventKeepsExplicitProxySnapshotPerAttempt(t *testing.T) {
  31. gin.SetMode(gin.TestMode)
  32. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  33. oldProxyID := int64(10060)
  34. oldProxy := &Proxy{
  35. ID: oldProxyID,
  36. Name: "wldsg82-ipv6-10060",
  37. Protocol: "socks5",
  38. Host: "old-proxy.example",
  39. Username: "proxy-user",
  40. Password: "proxy-secret",
  41. }
  42. oldAccount := &Account{
  43. ID: 11,
  44. Name: "old-account",
  45. Platform: PlatformOpenAI,
  46. ProxyID: &oldProxyID,
  47. Proxy: oldProxy,
  48. }
  49. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  50. AccountID: oldAccount.ID,
  51. ProxyID: opsUpstreamProxyID(oldAccount),
  52. ProxyName: opsUpstreamProxyName(oldAccount),
  53. Kind: "retry",
  54. })
  55. newProxyID := int64(8001)
  56. newAccount := &Account{
  57. ID: 12,
  58. Name: "new-account",
  59. Platform: PlatformOpenAI,
  60. ProxyID: &newProxyID,
  61. Proxy: &Proxy{ID: newProxyID, Name: "oxylabs-uk-8001"},
  62. }
  63. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  64. AccountID: newAccount.ID,
  65. ProxyID: opsUpstreamProxyID(newAccount),
  66. ProxyName: opsUpstreamProxyName(newAccount),
  67. Kind: "failover",
  68. })
  69. // Mutating the selected account after both attempts must not rewrite history.
  70. oldProxy.Name = "mutated-current-proxy"
  71. oldAccount.ProxyID = &newProxyID
  72. oldAccount.Proxy = newAccount.Proxy
  73. raw, ok := c.Get(OpsUpstreamErrorsKey)
  74. require.True(t, ok)
  75. events, ok := raw.([]*OpsUpstreamErrorEvent)
  76. require.True(t, ok)
  77. require.Len(t, events, 2)
  78. require.Equal(t, int64(10060), *events[0].ProxyID)
  79. require.Equal(t, "wldsg82-ipv6-10060", events[0].ProxyName)
  80. require.Equal(t, int64(8001), *events[1].ProxyID)
  81. require.Equal(t, "oxylabs-uk-8001", events[1].ProxyName)
  82. encoded := marshalOpsUpstreamErrors(events)
  83. require.NotNil(t, encoded)
  84. require.NotContains(t, *encoded, "old-proxy.example")
  85. require.NotContains(t, *encoded, "proxy-user")
  86. require.NotContains(t, *encoded, "proxy-secret")
  87. }
  88. func TestOpsUpstreamProxyFieldAccessors(t *testing.T) {
  89. gin.SetMode(gin.TestMode)
  90. boundID := int64(7)
  91. backupID := int64(8)
  92. tests := []struct {
  93. name string
  94. account *Account
  95. wantID *int64
  96. wantName string
  97. }{
  98. {
  99. name: "actual proxy object is authoritative",
  100. account: &Account{ProxyID: &boundID, Proxy: &Proxy{ID: backupID, Name: "backup-uk"}},
  101. wantID: &backupID,
  102. wantName: "backup-uk",
  103. },
  104. {
  105. name: "proxy object without binding id is not a configured route",
  106. account: &Account{Proxy: &Proxy{ID: backupID, Name: "hydrated-proxy"}},
  107. wantName: opsProxyNameDirect,
  108. },
  109. {
  110. name: "hydrated proxy without durable id is unknown, never id=null plus a real name",
  111. account: &Account{ProxyID: &boundID, Proxy: &Proxy{Name: "transient-proxy"}},
  112. wantName: opsProxyNameUnknown,
  113. },
  114. {
  115. name: "blank proxy name gets the unnamed placeholder",
  116. account: &Account{ProxyID: &boundID, Proxy: &Proxy{ID: boundID, Name: " "}},
  117. wantID: &boundID,
  118. wantName: opsProxyNameUnnamed,
  119. },
  120. {
  121. name: "account has no proxy",
  122. account: &Account{},
  123. wantName: opsProxyNameDirect,
  124. },
  125. {
  126. name: "configured proxy was not hydrated and transport is direct",
  127. account: &Account{ProxyID: &backupID},
  128. wantName: opsProxyNameDirect,
  129. },
  130. {
  131. name: "missing attempt account",
  132. account: nil,
  133. wantName: opsProxyNameUnknown,
  134. },
  135. }
  136. for _, tt := range tests {
  137. t.Run(tt.name, func(t *testing.T) {
  138. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  139. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  140. ProxyID: opsUpstreamProxyID(tt.account),
  141. ProxyName: opsUpstreamProxyName(tt.account),
  142. Kind: "request_error",
  143. })
  144. raw, ok := c.Get(OpsUpstreamErrorsKey)
  145. require.True(t, ok)
  146. events, ok := raw.([]*OpsUpstreamErrorEvent)
  147. require.True(t, ok)
  148. require.Len(t, events, 1)
  149. event := events[0]
  150. if tt.wantID == nil {
  151. require.Nil(t, event.ProxyID)
  152. } else {
  153. require.NotNil(t, event.ProxyID)
  154. require.Equal(t, *tt.wantID, *event.ProxyID)
  155. }
  156. require.Equal(t, tt.wantName, event.ProxyName)
  157. })
  158. }
  159. }
  160. func TestOpsUpstreamErrorEventRetryKeepsExplicitAttemptProxy(t *testing.T) {
  161. gin.SetMode(gin.TestMode)
  162. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  163. proxyID := int64(10060)
  164. account := &Account{ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "retry-proxy"}}
  165. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  166. ProxyID: opsUpstreamProxyID(account),
  167. ProxyName: opsUpstreamProxyName(account),
  168. Kind: "retry",
  169. })
  170. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  171. ProxyID: opsUpstreamProxyID(account),
  172. ProxyName: opsUpstreamProxyName(account),
  173. Kind: "retry_exhausted",
  174. })
  175. raw, ok := c.Get(OpsUpstreamErrorsKey)
  176. require.True(t, ok)
  177. events, ok := raw.([]*OpsUpstreamErrorEvent)
  178. require.True(t, ok)
  179. require.Len(t, events, 2)
  180. for _, event := range events {
  181. require.NotNil(t, event.ProxyID)
  182. require.Equal(t, proxyID, *event.ProxyID)
  183. require.Equal(t, "retry-proxy", event.ProxyName)
  184. }
  185. }
  186. func TestNormalizeOpsUpstreamProxyAttributionEnforcesSentinelInvariant(t *testing.T) {
  187. positive := int64(7)
  188. zero := int64(0)
  189. tests := []struct {
  190. name string
  191. in OpsUpstreamErrorEvent
  192. wantID *int64
  193. wantName string
  194. }{
  195. {name: "id with name kept", in: OpsUpstreamErrorEvent{ProxyID: &positive, ProxyName: "eu-1"}, wantID: &positive, wantName: "eu-1"},
  196. {name: "id without name gets placeholder", in: OpsUpstreamErrorEvent{ProxyID: &positive}, wantID: &positive, wantName: opsProxyNameUnnamed},
  197. {name: "null id with real name collapses to unknown", in: OpsUpstreamErrorEvent{ProxyName: "eu-1"}, wantName: opsProxyNameUnknown},
  198. {name: "zero id with real name collapses to unknown", in: OpsUpstreamErrorEvent{ProxyID: &zero, ProxyName: "eu-1"}, wantName: opsProxyNameUnknown},
  199. {name: "explicit direct preserved", in: OpsUpstreamErrorEvent{ProxyName: opsProxyNameDirect}, wantName: opsProxyNameDirect},
  200. {name: "empty is unknown", in: OpsUpstreamErrorEvent{}, wantName: opsProxyNameUnknown},
  201. }
  202. for _, tt := range tests {
  203. t.Run(tt.name, func(t *testing.T) {
  204. ev := tt.in
  205. normalizeOpsUpstreamProxyAttribution(&ev)
  206. if tt.wantID == nil {
  207. require.Nil(t, ev.ProxyID)
  208. } else {
  209. require.NotNil(t, ev.ProxyID)
  210. require.Equal(t, *tt.wantID, *ev.ProxyID)
  211. }
  212. require.Equal(t, tt.wantName, ev.ProxyName)
  213. })
  214. }
  215. }
  216. func TestNormalizeOpsUpstreamErrorsJSONPreservesUnknownKeysAndOrder(t *testing.T) {
  217. // A legacy row written by an older struct version: it carries a key the
  218. // current struct no longer has, and mixes explicit/legacy attribution.
  219. raw := `[` +
  220. `{"at_unix_ms":1,"account_id":42,"upstream_request_body":"legacy-only-key","kind":"http_error"},` +
  221. `{"at_unix_ms":2,"proxy_id":null,"proxy_name":"direct/no_proxy","kind":"request_error"},` +
  222. `{"at_unix_ms":3,"proxy_id":9,"proxy_name":"eu-9","kind":"failover"},` +
  223. `{"at_unix_ms":4,"proxy_id":0,"proxy_name":"stale-name","kind":"retry"},` +
  224. `{"at_unix_ms":5,"proxy_id":11,"proxy_name":"","kind":"retry"}` +
  225. `]`
  226. out, err := normalizeOpsUpstreamErrorsJSON(raw)
  227. require.NoError(t, err)
  228. // Legacy-only key and original key order survive.
  229. require.Contains(t, out, `"upstream_request_body":"legacy-only-key"`)
  230. require.True(t, strings.Index(out, `"at_unix_ms":1`) < strings.Index(out, `"account_id":42`))
  231. events, err := ParseOpsUpstreamErrors(out)
  232. require.NoError(t, err)
  233. require.Len(t, events, 5)
  234. require.Nil(t, events[0].ProxyID)
  235. require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
  236. require.Nil(t, events[1].ProxyID)
  237. require.Equal(t, opsProxyNameDirect, events[1].ProxyName)
  238. require.NotNil(t, events[2].ProxyID)
  239. require.Equal(t, int64(9), *events[2].ProxyID)
  240. require.Equal(t, "eu-9", events[2].ProxyName)
  241. require.Nil(t, events[3].ProxyID)
  242. require.Equal(t, opsProxyNameUnknown, events[3].ProxyName)
  243. require.NotNil(t, events[4].ProxyID)
  244. require.Equal(t, opsProxyNameUnnamed, events[4].ProxyName)
  245. // Already-normalized input is returned byte-for-byte.
  246. again, err := normalizeOpsUpstreamErrorsJSON(out)
  247. require.NoError(t, err)
  248. require.Equal(t, out, again)
  249. _, err = normalizeOpsUpstreamErrorsJSON(`{"not":"an array"}`)
  250. require.Error(t, err)
  251. _, err = normalizeOpsUpstreamErrorsJSON(`[not json`)
  252. require.Error(t, err)
  253. }
  254. func TestParseOpsUpstreamErrorsMarksLegacyProxyAttributionUnknown(t *testing.T) {
  255. events, err := ParseOpsUpstreamErrors(`[{"account_id":42,"account_name":"legacy","kind":"http_error"}]`)
  256. require.NoError(t, err)
  257. require.Len(t, events, 1)
  258. require.Nil(t, events[0].ProxyID)
  259. require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
  260. }
  261. func TestParseOpsUpstreamErrorsPreservesExplicitDirectAttribution(t *testing.T) {
  262. events, err := ParseOpsUpstreamErrors(`[{"proxy_id":null,"proxy_name":"direct/no_proxy","kind":"request_error"}]`)
  263. require.NoError(t, err)
  264. require.Len(t, events, 1)
  265. require.Nil(t, events[0].ProxyID)
  266. require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
  267. }
  268. func TestOpsServiceGetErrorLogByIDNormalizesLegacyProxyAttribution(t *testing.T) {
  269. repo := &opsRepoMock{
  270. GetErrorLogByIDFn: func(context.Context, int64) (*OpsErrorLogDetail, error) {
  271. return &OpsErrorLogDetail{UpstreamErrors: `[{"account_id":42,"kind":"http_error"}]`}, nil
  272. },
  273. }
  274. svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  275. detail, err := svc.GetErrorLogByID(context.Background(), 1)
  276. require.NoError(t, err)
  277. require.NotNil(t, detail)
  278. require.Contains(t, detail.UpstreamErrors, `"proxy_name":"unknown"`)
  279. require.Contains(t, detail.UpstreamErrors, `"proxy_id":null`)
  280. require.NotContains(t, detail.UpstreamErrors, `"proxy_mode"`)
  281. require.NotContains(t, detail.UpstreamErrors, `"proxy_source"`)
  282. require.NotContains(t, detail.UpstreamErrors, `"proxy_fallback"`)
  283. }