test_proxy_auth.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. """Proxy recovery against loopback HTTP only; all credentials are synthetic."""
  2. from concurrent.futures import ThreadPoolExecutor
  3. from dataclasses import replace
  4. import hashlib
  5. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  6. import json
  7. import os
  8. from pathlib import Path
  9. import tempfile
  10. import threading
  11. from types import SimpleNamespace
  12. import unittest
  13. from unittest.mock import patch
  14. from urllib.parse import parse_qs, urlsplit
  15. from src.proxy_auth import ProxyAuthError, ProxyTokens, proxy_identity, replace_token, token_rejected
  16. from src.step1_knowledge_prepare import summarize
  17. from src.step3_question_answer import qcc
  18. from src.step3_question_answer.streaming import stream_transport
  19. from scripts import deploy_check
  20. class TokenStateTests(unittest.TestCase):
  21. url = 'http://example.invalid:10011/proxy/?servertype=deepseek&proxyToken=synthetic-old'
  22. def client(self, login=None):
  23. client = SimpleNamespace(config=SimpleNamespace(username='synthetic-user', password='synthetic-password'),
  24. login_url='http://example.invalid/login', token='synthetic-short')
  25. client._login = login or (lambda: None)
  26. return client
  27. def test_only_definite_outer_rejections(self):
  28. for value in ({'code': 212}, {'code': '212'}):
  29. self.assertTrue(token_rejected(value))
  30. for value in ({'code': 201}, {'Status': '115'}, {'code': 212, 'choices': []},
  31. {'code': 212, 'Status': '200'}, {'message': 'token invalid'}, [], None):
  32. self.assertFalse(token_rejected(value))
  33. def test_diagnostics_expose_only_fixed_auth_codes(self):
  34. from src.step4_api.logging import safe_code
  35. for code in ('proxy_token_invalid', 'proxy_token_refresh_failed'):
  36. self.assertEqual(deploy_check.safe_code(RuntimeError(code)), code)
  37. self.assertEqual(safe_code(code), code)
  38. self.assertEqual(safe_code('model_' + code), 'model_' + code)
  39. self.assertEqual(deploy_check.safe_code(RuntimeError('synthetic-token-in-url')), 'request_failed')
  40. def test_replace_preserves_signed_query_and_encodes_token(self):
  41. url = self.url + '&raw=%2f+%20&x=1&x=2&blank=&flag'
  42. updated = replace_token(url, 'synthetic+/&=?')
  43. self.assertEqual(updated, url.replace('synthetic-old', 'synthetic%2B%2F%26%3D%3F'))
  44. self.assertEqual(proxy_identity(updated)[1], 'synthetic+/&=?')
  45. self.assertIsNone(proxy_identity(url + '&proxyToken=duplicate'))
  46. def test_singleflight_and_origin_isolation(self):
  47. calls = []
  48. auth = ProxyTokens(lambda: self.client(lambda: calls.append('login')))
  49. barrier = threading.Barrier(8)
  50. def refresh(_):
  51. barrier.wait()
  52. auth.refresh(self.url)
  53. return auth.url(self.url)
  54. with ThreadPoolExecutor(max_workers=8) as pool:
  55. urls = list(pool.map(refresh, range(8)))
  56. self.assertEqual(calls, ['login'])
  57. self.assertEqual(set(urls), {self.url.replace('synthetic-old', 'synthetic-short')})
  58. for other in (self.url.replace('http:', 'https:'), self.url.replace(':10011', ':10012'),
  59. self.url.replace('example.invalid', 'other.invalid')):
  60. self.assertEqual(auth.url(other), other)
  61. self.assertEqual(repr(auth), '<ProxyTokens>')
  62. def test_failure_cooldown_and_later_recovery(self):
  63. now, calls = [10], []
  64. def factory():
  65. calls.append(1)
  66. if len(calls) == 1:
  67. raise RuntimeError('synthetic-password')
  68. return self.client()
  69. auth = ProxyTokens(factory, lambda: now[0])
  70. for _ in range(2):
  71. with self.assertRaisesRegex(ProxyAuthError, '^proxy_token_refresh_failed$'):
  72. auth.refresh(self.url)
  73. self.assertEqual(calls, [1])
  74. now[0] += 31
  75. auth.refresh(self.url)
  76. self.assertIn('synthetic-short', auth.url(self.url))
  77. def test_missing_login_same_or_malformed_token_fails_closed(self):
  78. for changes in ({'login_url': None}, {'token': 'synthetic-old'}, {'token': 'bad\nvalue'},
  79. {'token': ''}, {'token': None}, {'config': SimpleNamespace(username='', password='')}):
  80. with self.subTest(changes=changes):
  81. client = self.client()
  82. for name, value in changes.items():
  83. setattr(client, name, value)
  84. auth = ProxyTokens(lambda: client)
  85. with self.assertRaisesRegex(ProxyAuthError, '^proxy_token_refresh_failed$'):
  86. auth.refresh(self.url)
  87. self.assertEqual(auth.url(self.url), self.url)
  88. def test_fresh_rejection_cools_down_without_relogin(self):
  89. calls, now = [], [10]
  90. auth = ProxyTokens(lambda: self.client(lambda: calls.append(1)), lambda: now[0])
  91. auth.refresh(self.url)
  92. new = auth.url(self.url)
  93. auth.reject(new)
  94. with self.assertRaisesRegex(ProxyAuthError, '^proxy_token_refresh_failed$'):
  95. auth.refresh(new)
  96. self.assertEqual(calls, [1])
  97. class RecoveryHttpTests(unittest.TestCase):
  98. def setUp(self):
  99. self.calls, self.logins = [], []
  100. self.valid = 'synthetic-short'
  101. self.mode, self.login_mode, self.rejection_status = 'normal', 'normal', 200
  102. self.first_received = threading.Event()
  103. test = self
  104. class Handler(BaseHTTPRequestHandler):
  105. protocol_version = 'HTTP/1.1'
  106. def log_message(self, *_):
  107. pass
  108. def handle(self):
  109. try:
  110. super().handle()
  111. except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
  112. pass
  113. def envelope(self, value, status=200):
  114. body = json.dumps(value).encode()
  115. self.send_response(status)
  116. self.send_header('Content-Length', str(len(body)))
  117. self.end_headers()
  118. self.wfile.write(body)
  119. def do_POST(self):
  120. body = self.rfile.read(int(self.headers['Content-Length']))
  121. if self.path == '/oauth/api/user/login':
  122. test.logins.append((self.path, parse_qs(body.decode()), dict(self.headers)))
  123. if test.login_mode == 'redirect':
  124. self.send_response(302)
  125. self.send_header('Location', '/do-not-follow')
  126. self.send_header('Content-Length', '0')
  127. self.end_headers()
  128. else:
  129. self.envelope({'code': 201 if test.login_mode == 'denied' else 200,
  130. 'message': test.valid, 'content': None})
  131. return
  132. self.proxy(json.loads(body))
  133. def do_GET(self):
  134. self.proxy(None)
  135. def proxy(self, payload):
  136. query = parse_qs(urlsplit(self.path).query)
  137. test.calls.append((self.path, dict(self.headers), payload))
  138. if test.mode == 'permission':
  139. self.envelope({'code': 201})
  140. elif test.mode == 'upstream_auth':
  141. self.envelope({'Status': '115'})
  142. elif test.mode == 'http401':
  143. self.envelope({'message': 'unauthorized'}, 401)
  144. elif test.mode == 'http500':
  145. self.envelope({'code': 212}, 500)
  146. elif test.mode == 'truncated_rejection':
  147. body = b'{"code":212}'
  148. self.send_response(401)
  149. self.send_header('Content-Length', str(len(body) + 10))
  150. self.end_headers()
  151. self.wfile.write(body)
  152. self.close_connection = True
  153. elif test.mode == 'invalid_json':
  154. self.send_response(200)
  155. self.send_header('Content-Length', '1')
  156. self.end_headers()
  157. self.wfile.write(b'{')
  158. elif test.mode == 'always_invalid' or query['proxyToken'][0] != test.valid:
  159. if test.mode == 'whitespace' and payload is not None:
  160. self.send_response(200)
  161. self.send_header('Transfer-Encoding', 'chunked')
  162. self.end_headers()
  163. self.chunk(b' \n ')
  164. self.chunk(b'{"code":212}')
  165. self.wfile.write(b'0\r\n\r\n')
  166. else:
  167. self.envelope({'code': '212', 'message': 'synthetic rejection'}, test.rejection_status)
  168. elif payload is None:
  169. service = query['servertype'][0]
  170. company = {'KeyNo': 'synthetic-id', 'Name': '合成企业'}
  171. result = ([company] if service == 'fuzzySearch' else company
  172. if service == 'basicDetails' else {'VerifyResult': 0, 'Data': []})
  173. self.envelope({'Status': '200', 'Result': result})
  174. elif payload.get('stream'):
  175. self.send_response(200)
  176. self.send_header('Content-Type', 'text/event-stream')
  177. self.send_header('Transfer-Encoding', 'chunked')
  178. self.end_headers()
  179. self.chunk(b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n')
  180. if test.mode == 'partial':
  181. self.close_connection = True
  182. return
  183. if test.mode == 'incremental' and not test.first_received.wait(2):
  184. self.close_connection = True
  185. return
  186. self.chunk(b'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n')
  187. self.wfile.write(b'0\r\n\r\n')
  188. else:
  189. self.envelope({'choices': [{'message': {'content': 'OK'}}]})
  190. def chunk(self, body):
  191. self.wfile.write(f'{len(body):x}\r\n'.encode() + body + b'\r\n')
  192. self.wfile.flush()
  193. self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
  194. self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=.02), daemon=True)
  195. self.thread.start()
  196. self.temp = tempfile.TemporaryDirectory()
  197. self.path = Path(self.temp.name) / '.env'
  198. base = f'http://127.0.0.1:{self.server.server_port}'
  199. query = '?servertype={}&proxyToken=synthetic-old&raw=%2f+%20&x=1&x=2'
  200. lines = ['QCC_KEY=synthetic-key', 'QCC_SECRET_KEY=synthetic-secret', 'QCC_TIMEOUT_SECONDS=2',
  201. 'DEEPSEEK_API_KEY=synthetic-api-key', 'DEEPSEEK_MODEL=synthetic-model', 'SUMMARY_TIMEOUT_SECONDS=2',
  202. 'DMS_BASE_URL=http://127.0.0.1', f'AUTH_PORT={self.server.server_port}',
  203. f'DMS_PORT={self.server.server_port}', 'DMS_USERNAME=synthetic-user', 'DMS_PASSWORD=synthetic-password',
  204. 'DEEPSEEK_BASE_URL=' + base + '/proxy/chat/completions' + query.format('deepseek')]
  205. for name, service in (('SEARCH', 'fuzzySearch'), ('BASIC', 'basicDetails'), ('HONOR', 'honor')):
  206. lines.append('QCC_' + name + '_URL=' + base + '/proxy/' + query.format(service))
  207. self.path.write_text('\n'.join(lines), encoding='utf-8')
  208. self.original = self.path.read_bytes()
  209. self.env = patch.dict(os.environ, {'NO_PROXY': '*'}, clear=True)
  210. self.env.start()
  211. self.qconfig, self.mconfig = qcc.load_config(self.path), summarize.load_config(self.path)
  212. def tearDown(self):
  213. self.assertEqual(self.path.read_bytes(), self.original, '.env must stay byte-identical')
  214. self.env.stop()
  215. self.server.shutdown()
  216. self.server.server_close()
  217. self.thread.join()
  218. self.temp.cleanup()
  219. def model(self):
  220. return summarize.transport({'model': self.mconfig.model, 'messages': [{'role': 'user', 'content': 'synthetic'}]}, self.mconfig)
  221. def test_qcc_login_shared_with_other_interfaces_and_model(self):
  222. self.assertIs(self.qconfig.proxy_auth, self.mconfig.proxy_auth)
  223. client = qcc.QccClient(self.qconfig)
  224. company = client.search('合成企业')['candidates'][0]
  225. client.basic(company)
  226. self.assertTrue(client.honors(company)['complete'])
  227. self.assertEqual(self.model()['choices'][0]['message']['content'], 'OK')
  228. self.assertEqual(len(self.logins), 1)
  229. self.assertEqual(len(self.calls), 5)
  230. first, replay = self.calls[:2]
  231. self.assertEqual(first[0].replace('synthetic-old', 'synthetic-short'), replay[0])
  232. for name in ('Token', 'Timespan'):
  233. self.assertEqual(first[1][name], replay[1][name])
  234. expected = hashlib.md5(('synthetic-key' + replay[1]['Timespan'] + 'synthetic-secret').encode()).hexdigest().upper()
  235. self.assertEqual(replay[1]['Token'], expected)
  236. self.assertEqual(self.logins[0][1], {'userName': ['synthetic-user'], 'password': ['synthetic-password'],
  237. 'clientId': ['0'], 'serviceId': ['0']})
  238. self.assertNotIn('Token', self.logins[0][2])
  239. self.assertEqual(self.calls[-1][1]['Authorization'], 'Bearer synthetic-api-key')
  240. for path, headers, body in self.calls:
  241. self.assertIn('raw=%2f+%20&x=1&x=2', path)
  242. self.assertNotIn('synthetic-password', str(headers) + str(body) + path)
  243. def test_valid_long_token_needs_no_dms(self):
  244. self.valid = 'synthetic-old'
  245. with patch('src.step5_session_analysis.dms.load_dms_config', side_effect=AssertionError('no login expected')):
  246. self.model()
  247. qcc.QccClient(self.qconfig).search('合成企业')
  248. self.assertEqual(self.logins, [])
  249. def test_healthy_json_is_strictly_decoded_only_once(self):
  250. self.valid = 'synthetic-old'
  251. with patch.object(summarize, 'strict_json', wraps=summarize.strict_json) as decode:
  252. self.model()
  253. self.assertEqual(decode.call_count, 1)
  254. def test_model_json_replays_exact_payload_once(self):
  255. self.model()
  256. self.assertEqual(len(self.calls), 2)
  257. self.assertEqual(self.calls[0][2], self.calls[1][2])
  258. self.assertEqual(len(self.logins), 1)
  259. self.valid = 'synthetic-next'
  260. self.model()
  261. self.assertEqual(len(self.calls), 4)
  262. self.assertEqual(len(self.logins), 2)
  263. self.assertIn('proxyToken=synthetic-next', self.calls[-1][0])
  264. def test_stream_recovery_keeps_first_delta_incremental(self):
  265. self.mode = 'incremental'
  266. parts = []
  267. def consume(text):
  268. parts.append(text)
  269. self.first_received.set()
  270. result = stream_transport({'model': 'synthetic-model'}, self.mconfig, consume)
  271. self.assertEqual(parts, ['first', 'OK'])
  272. self.assertEqual(result['choices'][0]['message']['content'], 'firstOK')
  273. self.assertEqual(len(self.calls), 2)
  274. self.assertEqual(len(self.logins), 1)
  275. self.assertEqual(self.calls[0][2], self.calls[1][2])
  276. def test_whitespace_fragmented_rejection(self):
  277. self.mode = 'whitespace'
  278. self.assertEqual(self.model()['choices'][0]['message']['content'], 'OK')
  279. self.assertEqual(len(self.logins), 1)
  280. def test_http_auth_requires_explicit_212(self):
  281. for status in (401, 403):
  282. self.rejection_status = status
  283. self.valid = 'synthetic-' + str(status)
  284. self.model()
  285. self.assertIn('proxyToken=' + self.valid, self.calls[-1][0])
  286. self.assertEqual(len(self.logins), 2)
  287. # QCC uses the same outer rejection contract for HTTP auth failures.
  288. self.valid = 'synthetic-qcc'
  289. qcc.QccClient(self.qconfig).search('合成企业')
  290. self.assertEqual(len(self.logins), 3)
  291. def test_unrelated_failures_do_not_log_in(self):
  292. for mode, model_code, qcc_code in (
  293. ('permission', None, 'provider_failure'), ('upstream_auth', None, 'qcc_auth_failed'),
  294. ('http401', 'http_401', 'http_401'), ('http500', 'http_500', 'http_500'),
  295. ('invalid_json', 'invalid_response_json', 'invalid_json')):
  296. self.mode = mode
  297. if model_code is None:
  298. self.model() # Original provider schema validation belongs to the caller.
  299. else:
  300. with self.assertRaisesRegex(summarize.RequestFailure, '^' + model_code + '$'):
  301. self.model()
  302. with self.assertRaisesRegex(qcc.QccError, '^' + qcc_code + '$'):
  303. qcc.QccClient(self.qconfig).search('合成企业')
  304. self.assertEqual(self.logins, [])
  305. self.assertEqual(len(self.calls), 10)
  306. def test_second_rejection_stops_and_cools_down(self):
  307. self.mode = 'always_invalid'
  308. with self.assertRaisesRegex(summarize.RequestFailure, '^proxy_token_invalid$') as failure:
  309. self.model()
  310. self.assertFalse(failure.exception.retryable)
  311. self.assertEqual(len(self.calls), 2)
  312. with self.assertRaisesRegex(qcc.QccError, '^proxy_token_refresh_failed$'):
  313. qcc.QccClient(self.qconfig).search('合成企业')
  314. self.assertEqual(len(self.logins), 1)
  315. self.assertEqual(len(self.calls), 3)
  316. def test_truncated_auth_envelope_is_not_a_definite_rejection(self):
  317. self.mode = 'truncated_rejection'
  318. with self.assertRaisesRegex(summarize.RequestFailure, '^response_incomplete$'):
  319. self.model()
  320. with self.assertRaisesRegex(qcc.QccError, '^response_incomplete$'):
  321. qcc.QccClient(self.qconfig).search('合成企业')
  322. self.assertEqual(self.logins, [])
  323. self.assertEqual(len(self.calls), 2)
  324. def test_qcc_second_rejection_is_bounded(self):
  325. self.mode = 'always_invalid'
  326. with self.assertRaisesRegex(qcc.QccError, '^proxy_token_invalid$'):
  327. qcc.QccClient(self.qconfig).search('合成企业')
  328. self.assertEqual(len(self.calls), 2)
  329. self.assertEqual(len(self.logins), 1)
  330. def test_login_failure_and_redirect_are_safe_and_not_replayed(self):
  331. for mode in ('denied', 'redirect'):
  332. self.login_mode = mode
  333. # Isolate the two scenarios' shared caches using an explicit test env.
  334. config = summarize.load_config(self.path, {})
  335. with self.assertRaisesRegex(summarize.RequestFailure, '^proxy_token_refresh_failed$') as failure:
  336. summarize.transport({}, config)
  337. self.assertFalse(failure.exception.retryable)
  338. self.assertEqual(len(self.logins), 2)
  339. self.assertEqual(len(self.calls), 2)
  340. def test_partial_stream_is_never_replayed(self):
  341. self.valid, self.mode = 'synthetic-old', 'partial'
  342. parts = []
  343. with self.assertRaisesRegex(summarize.RequestFailure, '^response_incomplete$'):
  344. stream_transport({}, self.mconfig, parts.append)
  345. self.assertEqual(parts, ['first'])
  346. self.assertEqual(len(self.calls), 1)
  347. self.assertEqual(self.logins, [])
  348. def test_cancellation_after_login_prevents_replay(self):
  349. def check():
  350. if self.logins:
  351. raise summarize.RequestFailure('stream_cancelled', False)
  352. with self.assertRaisesRegex(summarize.RequestFailure, '^stream_cancelled$'):
  353. stream_transport({}, self.mconfig, lambda _: None, check)
  354. self.assertEqual(len(self.calls), 1)
  355. self.assertEqual(len(self.logins), 1)
  356. def test_direct_qcc_envelope_does_not_use_proxy_auth(self):
  357. config = replace(self.qconfig, search_url=qcc.DEFAULT_SEARCH_URL)
  358. with self.assertRaisesRegex(qcc.QccError, '^provider_failure$'):
  359. qcc.QccClient(config, send=lambda *_: {'code': 212}).search('合成企业')
  360. self.assertEqual(self.logins, [])
  361. if __name__ == '__main__':
  362. unittest.main()