proxy-forwarder-fake-200-html.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
  3. const mocks = vi.hoisted(() => {
  4. return {
  5. pickRandomProviderWithExclusion: vi.fn(),
  6. recordSuccess: vi.fn(),
  7. recordFailure: vi.fn(async () => {}),
  8. getCircuitState: vi.fn(() => "closed"),
  9. getProviderHealthInfo: vi.fn(async () => ({
  10. health: { failureCount: 0 },
  11. config: { failureThreshold: 3 },
  12. })),
  13. updateMessageRequestDetails: vi.fn(async () => {}),
  14. isHttp2Enabled: vi.fn(async () => false),
  15. getPreferredProviderEndpoints: vi.fn(async () => []),
  16. getEndpointFilterStats: vi.fn(async () => null),
  17. recordEndpointSuccess: vi.fn(async () => {}),
  18. recordEndpointFailure: vi.fn(async () => {}),
  19. isVendorTypeCircuitOpen: vi.fn(async () => false),
  20. recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}),
  21. // ErrorCategory.PROVIDER_ERROR
  22. categorizeErrorAsync: vi.fn(async () => 0),
  23. };
  24. });
  25. vi.mock("@/lib/logger", () => ({
  26. logger: {
  27. debug: vi.fn(),
  28. info: vi.fn(),
  29. warn: vi.fn(),
  30. trace: vi.fn(),
  31. error: vi.fn(),
  32. fatal: vi.fn(),
  33. },
  34. }));
  35. vi.mock("@/lib/config", async (importOriginal) => {
  36. const actual = await importOriginal<typeof import("@/lib/config")>();
  37. return {
  38. ...actual,
  39. isHttp2Enabled: mocks.isHttp2Enabled,
  40. };
  41. });
  42. vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({
  43. getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints,
  44. getEndpointFilterStats: mocks.getEndpointFilterStats,
  45. }));
  46. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  47. recordEndpointSuccess: mocks.recordEndpointSuccess,
  48. recordEndpointFailure: mocks.recordEndpointFailure,
  49. }));
  50. vi.mock("@/lib/circuit-breaker", () => ({
  51. getCircuitState: mocks.getCircuitState,
  52. getProviderHealthInfo: mocks.getProviderHealthInfo,
  53. recordFailure: mocks.recordFailure,
  54. recordSuccess: mocks.recordSuccess,
  55. }));
  56. vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
  57. isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen,
  58. recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout,
  59. }));
  60. vi.mock("@/repository/message", () => ({
  61. updateMessageRequestDetails: mocks.updateMessageRequestDetails,
  62. }));
  63. vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({
  64. ProxyProviderResolver: {
  65. pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion,
  66. },
  67. }));
  68. vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
  69. const actual = await importOriginal<typeof import("@/app/v1/_lib/proxy/errors")>();
  70. return {
  71. ...actual,
  72. categorizeErrorAsync: mocks.categorizeErrorAsync,
  73. };
  74. });
  75. import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  76. import { ProxyError } from "@/app/v1/_lib/proxy/errors";
  77. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  78. import type { Provider } from "@/types/provider";
  79. function createProvider(overrides: Partial<Provider> = {}): Provider {
  80. return {
  81. id: 1,
  82. name: "p1",
  83. url: "https://provider.example.com",
  84. key: "k",
  85. providerVendorId: null,
  86. isEnabled: true,
  87. weight: 1,
  88. priority: 0,
  89. groupPriorities: null,
  90. costMultiplier: 1,
  91. groupTag: null,
  92. providerType: "claude",
  93. preserveClientIp: false,
  94. modelRedirects: null,
  95. allowedModels: null,
  96. mcpPassthroughType: "none",
  97. mcpPassthroughUrl: null,
  98. limit5hUsd: null,
  99. limitDailyUsd: null,
  100. dailyResetMode: "fixed",
  101. dailyResetTime: "00:00",
  102. limitWeeklyUsd: null,
  103. limitMonthlyUsd: null,
  104. limitTotalUsd: null,
  105. totalCostResetAt: null,
  106. limitConcurrentSessions: 0,
  107. maxRetryAttempts: 1,
  108. circuitBreakerFailureThreshold: 5,
  109. circuitBreakerOpenDuration: 1_800_000,
  110. circuitBreakerHalfOpenSuccessThreshold: 2,
  111. proxyUrl: null,
  112. proxyFallbackToDirect: false,
  113. firstByteTimeoutStreamingMs: 30_000,
  114. streamingIdleTimeoutMs: 10_000,
  115. requestTimeoutNonStreamingMs: 1_000,
  116. websiteUrl: null,
  117. faviconUrl: null,
  118. cacheTtlPreference: null,
  119. context1mPreference: null,
  120. codexReasoningEffortPreference: null,
  121. codexReasoningSummaryPreference: null,
  122. codexTextVerbosityPreference: null,
  123. codexParallelToolCallsPreference: null,
  124. anthropicMaxTokensPreference: null,
  125. anthropicThinkingBudgetPreference: null,
  126. anthropicAdaptiveThinking: null,
  127. geminiGoogleSearchPreference: null,
  128. tpm: 0,
  129. rpm: 0,
  130. rpd: 0,
  131. cc: 0,
  132. createdAt: new Date(),
  133. updatedAt: new Date(),
  134. deletedAt: null,
  135. ...overrides,
  136. };
  137. }
  138. function createSession(): ProxySession {
  139. const headers = new Headers();
  140. const session = Object.create(ProxySession.prototype);
  141. Object.assign(session, {
  142. startTime: Date.now(),
  143. method: "POST",
  144. requestUrl: new URL("https://example.com/v1/messages"),
  145. headers,
  146. originalHeaders: new Headers(headers),
  147. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  148. request: {
  149. model: "claude-test",
  150. log: "(test)",
  151. message: {
  152. model: "claude-test",
  153. messages: [{ role: "user", content: "hi" }],
  154. },
  155. },
  156. userAgent: null,
  157. context: null,
  158. clientAbortSignal: null,
  159. userName: "test-user",
  160. authState: { success: true, user: null, key: null, apiKey: null },
  161. provider: null,
  162. messageContext: null,
  163. sessionId: null,
  164. requestSequence: 1,
  165. originalFormat: "claude",
  166. providerType: null,
  167. originalModelName: null,
  168. originalUrlPathname: null,
  169. providerChain: [],
  170. cacheTtlResolved: null,
  171. context1mApplied: false,
  172. specialSettings: [],
  173. cachedPriceData: undefined,
  174. cachedBillingModelSource: undefined,
  175. endpointPolicy: resolveEndpointPolicy("/v1/messages"),
  176. isHeaderModified: () => false,
  177. });
  178. return session as ProxySession;
  179. }
  180. describe("ProxyForwarder - fake 200 HTML body", () => {
  181. beforeEach(() => {
  182. vi.clearAllMocks();
  183. });
  184. test("200 + text/html 的 HTML 页面应视为失败并切换供应商", async () => {
  185. const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
  186. const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
  187. const session = createSession();
  188. session.setProvider(provider1);
  189. mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
  190. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  191. const htmlBody = [
  192. "<!doctype html>",
  193. "<html><head><title>New API</title></head>",
  194. "<body>blocked</body></html>",
  195. ].join("\n");
  196. const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
  197. doForward.mockResolvedValueOnce(
  198. new Response(htmlBody, {
  199. status: 200,
  200. headers: {
  201. "content-type": "text/html; charset=utf-8",
  202. "content-length": String(htmlBody.length),
  203. },
  204. })
  205. );
  206. doForward.mockResolvedValueOnce(
  207. new Response(okJson, {
  208. status: 200,
  209. headers: {
  210. "content-type": "application/json; charset=utf-8",
  211. "content-length": String(okJson.length),
  212. },
  213. })
  214. );
  215. const response = await ProxyForwarder.send(session);
  216. expect(await response.text()).toContain("ok");
  217. expect(doForward).toHaveBeenCalledTimes(2);
  218. expect(doForward.mock.calls[0][1].id).toBe(1);
  219. expect(doForward.mock.calls[1][1].id).toBe(2);
  220. expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
  221. expect(mocks.recordFailure).toHaveBeenCalledWith(
  222. 1,
  223. expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
  224. );
  225. const failure1 = mocks.recordFailure.mock.calls[0]?.[1];
  226. expect(failure1).toBeInstanceOf(ProxyError);
  227. expect((failure1 as ProxyError).getClientSafeMessage()).toContain("HTML document");
  228. expect((failure1 as ProxyError).getClientSafeMessage()).toContain("Upstream detail:");
  229. expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
  230. expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
  231. });
  232. test("200 + text/html 但 body 是 JSON error 也应视为失败并切换供应商", async () => {
  233. const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
  234. const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
  235. const session = createSession();
  236. session.setProvider(provider1);
  237. mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
  238. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  239. const jsonErrorBody = JSON.stringify({ error: "upstream blocked" });
  240. const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
  241. doForward.mockResolvedValueOnce(
  242. new Response(jsonErrorBody, {
  243. status: 200,
  244. headers: {
  245. // 故意使用 text/html:模拟部分上游 Content-Type 错配但 body 仍为错误 JSON 的情况
  246. "content-type": "text/html; charset=utf-8",
  247. "content-length": String(jsonErrorBody.length),
  248. },
  249. })
  250. );
  251. doForward.mockResolvedValueOnce(
  252. new Response(okJson, {
  253. status: 200,
  254. headers: {
  255. "content-type": "application/json; charset=utf-8",
  256. "content-length": String(okJson.length),
  257. },
  258. })
  259. );
  260. const response = await ProxyForwarder.send(session);
  261. expect(await response.text()).toContain("ok");
  262. expect(doForward).toHaveBeenCalledTimes(2);
  263. expect(doForward.mock.calls[0][1].id).toBe(1);
  264. expect(doForward.mock.calls[1][1].id).toBe(2);
  265. expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
  266. expect(mocks.recordFailure).toHaveBeenCalledWith(
  267. 1,
  268. expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
  269. );
  270. const failure2 = mocks.recordFailure.mock.calls[0]?.[1];
  271. expect(failure2).toBeInstanceOf(ProxyError);
  272. expect((failure2 as ProxyError).getClientSafeMessage()).toContain("JSON body");
  273. expect((failure2 as ProxyError).getClientSafeMessage()).toContain("`error`");
  274. expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked");
  275. expect((failure2 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody);
  276. expect((failure2 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false);
  277. expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
  278. expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
  279. });
  280. test("200 + application/json 且有 Content-Length 的 JSON error 也应视为失败并切换供应商", async () => {
  281. const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
  282. const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
  283. const session = createSession();
  284. session.setProvider(provider1);
  285. mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
  286. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  287. const jsonErrorBody = JSON.stringify({ error: "upstream blocked" });
  288. const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
  289. doForward.mockResolvedValueOnce(
  290. new Response(jsonErrorBody, {
  291. status: 200,
  292. headers: {
  293. "content-type": "application/json; charset=utf-8",
  294. "content-length": String(jsonErrorBody.length),
  295. },
  296. })
  297. );
  298. doForward.mockResolvedValueOnce(
  299. new Response(okJson, {
  300. status: 200,
  301. headers: {
  302. "content-type": "application/json; charset=utf-8",
  303. "content-length": String(okJson.length),
  304. },
  305. })
  306. );
  307. const response = await ProxyForwarder.send(session);
  308. expect(await response.text()).toContain("ok");
  309. expect(doForward).toHaveBeenCalledTimes(2);
  310. expect(doForward.mock.calls[0][1].id).toBe(1);
  311. expect(doForward.mock.calls[1][1].id).toBe(2);
  312. expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
  313. expect(mocks.recordFailure).toHaveBeenCalledWith(
  314. 1,
  315. expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
  316. );
  317. const failure3 = mocks.recordFailure.mock.calls[0]?.[1];
  318. expect(failure3).toBeInstanceOf(ProxyError);
  319. expect((failure3 as ProxyError).getClientSafeMessage()).toContain("JSON body");
  320. expect((failure3 as ProxyError).getClientSafeMessage()).toContain("`error`");
  321. expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked");
  322. expect((failure3 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody);
  323. expect((failure3 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false);
  324. expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
  325. expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
  326. });
  327. test("假200 JSON error 命中 rate limit 关键字时,应推断为 429 并在决策链中标记为推断", async () => {
  328. const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
  329. const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
  330. const session = createSession();
  331. session.setProvider(provider1);
  332. mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
  333. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  334. const jsonErrorBody = JSON.stringify({ error: "Rate limit exceeded" });
  335. const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
  336. doForward.mockResolvedValueOnce(
  337. new Response(jsonErrorBody, {
  338. status: 200,
  339. headers: {
  340. "content-type": "application/json; charset=utf-8",
  341. "content-length": String(jsonErrorBody.length),
  342. },
  343. })
  344. );
  345. doForward.mockResolvedValueOnce(
  346. new Response(okJson, {
  347. status: 200,
  348. headers: {
  349. "content-type": "application/json; charset=utf-8",
  350. "content-length": String(okJson.length),
  351. },
  352. })
  353. );
  354. const response = await ProxyForwarder.send(session);
  355. expect(await response.text()).toContain("ok");
  356. expect(mocks.recordFailure).toHaveBeenCalledWith(
  357. 1,
  358. expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
  359. );
  360. const failure = mocks.recordFailure.mock.calls[0]?.[1];
  361. expect(failure).toBeInstanceOf(ProxyError);
  362. expect((failure as ProxyError).statusCode).toBe(429);
  363. expect((failure as ProxyError).upstreamError?.statusCodeInferred).toBe(true);
  364. const chain = session.getProviderChain();
  365. expect(
  366. chain.some(
  367. (item) =>
  368. item.id === 1 &&
  369. item.reason === "retry_failed" &&
  370. item.statusCode === 429 &&
  371. item.statusCodeInferred === true
  372. )
  373. ).toBe(true);
  374. });
  375. test("200 + 非法 Content-Length 时应按缺失处理,避免漏检 HTML 假200", async () => {
  376. const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
  377. const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
  378. const session = createSession();
  379. session.setProvider(provider1);
  380. mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
  381. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  382. const htmlErrorBody = "<!doctype html><html><body>blocked</body></html>";
  383. const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
  384. doForward.mockResolvedValueOnce(
  385. new Response(htmlErrorBody, {
  386. status: 200,
  387. headers: {
  388. // 故意不提供 html/json 的 Content-Type,覆盖“仅靠 body 嗅探”的假200检测分支
  389. "content-type": "text/plain; charset=utf-8",
  390. // 非法 Content-Length:parseInt("12abc") 会返回 12;修复后应视为非法并进入 body 检查分支
  391. "content-length": "12abc",
  392. },
  393. })
  394. );
  395. doForward.mockResolvedValueOnce(
  396. new Response(okJson, {
  397. status: 200,
  398. headers: {
  399. "content-type": "application/json; charset=utf-8",
  400. "content-length": String(okJson.length),
  401. },
  402. })
  403. );
  404. const response = await ProxyForwarder.send(session);
  405. expect(await response.text()).toContain("ok");
  406. expect(doForward).toHaveBeenCalledTimes(2);
  407. expect(doForward.mock.calls[0][1].id).toBe(1);
  408. expect(doForward.mock.calls[1][1].id).toBe(2);
  409. expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
  410. expect(mocks.recordFailure).toHaveBeenCalledWith(
  411. 1,
  412. expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
  413. );
  414. const failure = mocks.recordFailure.mock.calls[0]?.[1];
  415. expect(failure).toBeInstanceOf(ProxyError);
  416. expect((failure as ProxyError).upstreamError?.rawBody).toBe(htmlErrorBody);
  417. expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
  418. expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
  419. });
  420. test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => {
  421. const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
  422. const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
  423. const session = createSession();
  424. session.setProvider(provider1);
  425. mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
  426. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  427. const missingContentJson = JSON.stringify({ type: "message", content: [] });
  428. const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
  429. doForward.mockResolvedValueOnce(
  430. new Response(missingContentJson, {
  431. status: 200,
  432. headers: {
  433. "content-type": "application/json; charset=utf-8",
  434. // 故意不提供 content-length:覆盖 forwarder 的 clone + JSON 内容结构检查分支
  435. },
  436. })
  437. );
  438. doForward.mockResolvedValueOnce(
  439. new Response(okJson, {
  440. status: 200,
  441. headers: {
  442. "content-type": "application/json; charset=utf-8",
  443. "content-length": String(okJson.length),
  444. },
  445. })
  446. );
  447. const response = await ProxyForwarder.send(session);
  448. expect(await response.text()).toContain("ok");
  449. expect(doForward).toHaveBeenCalledTimes(2);
  450. expect(doForward.mock.calls[0][1].id).toBe(1);
  451. expect(doForward.mock.calls[1][1].id).toBe(2);
  452. expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
  453. expect(mocks.recordFailure).toHaveBeenCalledWith(
  454. 1,
  455. expect.objectContaining({ reason: "missing_content" })
  456. );
  457. expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
  458. expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
  459. });
  460. });
  461. describe("ProxyError.getClientSafeMessage - FAKE_200 sanitization", () => {
  462. test("upstream body 包含 JWT 和 email 时应被脱敏为 [JWT] / [EMAIL]", () => {
  463. const jwtToken =
  464. "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
  465. const email = "[email protected]";
  466. const body = `Authentication failed for ${email} with token ${jwtToken}`;
  467. const error = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, {
  468. body,
  469. providerId: 1,
  470. providerName: "p1",
  471. });
  472. const msg = error.getClientSafeMessage();
  473. expect(msg).toContain("[JWT]");
  474. expect(msg).toContain("[EMAIL]");
  475. expect(msg).not.toContain(jwtToken);
  476. expect(msg).not.toContain(email);
  477. expect(msg).toContain("Upstream detail:");
  478. });
  479. test("upstream body 包含 password=xxx 时应被脱敏", () => {
  480. const body = "Config error: password=s3cretValue in /etc/app.json";
  481. const error = new ProxyError("FAKE_200_HTML_BODY", 502, {
  482. body,
  483. providerId: 1,
  484. providerName: "p1",
  485. });
  486. const msg = error.getClientSafeMessage();
  487. expect(msg).not.toContain("s3cretValue");
  488. expect(msg).toContain("[PATH]");
  489. expect(msg).toContain("Upstream detail:");
  490. });
  491. });