| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- """Proxy recovery against loopback HTTP only; all credentials are synthetic."""
- from concurrent.futures import ThreadPoolExecutor
- from dataclasses import replace
- import hashlib
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
- import json
- import os
- from pathlib import Path
- import tempfile
- import threading
- from types import SimpleNamespace
- import unittest
- from unittest.mock import patch
- from urllib.parse import parse_qs, urlsplit
- from src.proxy_auth import ProxyAuthError, ProxyTokens, proxy_identity, replace_token, token_rejected
- from src.step1_knowledge_prepare import summarize
- from src.step3_question_answer import qcc
- from src.step3_question_answer.streaming import stream_transport
- from scripts import deploy_check
- class TokenStateTests(unittest.TestCase):
- url = 'http://example.invalid:10011/proxy/?servertype=deepseek&proxyToken=synthetic-old'
- def client(self, login=None):
- client = SimpleNamespace(config=SimpleNamespace(username='synthetic-user', password='synthetic-password'),
- login_url='http://example.invalid/login', token='synthetic-short')
- client._login = login or (lambda: None)
- return client
- def test_only_definite_outer_rejections(self):
- for value in ({'code': 212}, {'code': '212'}):
- self.assertTrue(token_rejected(value))
- for value in ({'code': 201}, {'Status': '115'}, {'code': 212, 'choices': []},
- {'code': 212, 'Status': '200'}, {'message': 'token invalid'}, [], None):
- self.assertFalse(token_rejected(value))
- def test_diagnostics_expose_only_fixed_auth_codes(self):
- from src.step4_api.logging import safe_code
- for code in ('proxy_token_invalid', 'proxy_token_refresh_failed'):
- self.assertEqual(deploy_check.safe_code(RuntimeError(code)), code)
- self.assertEqual(safe_code(code), code)
- self.assertEqual(safe_code('model_' + code), 'model_' + code)
- self.assertEqual(deploy_check.safe_code(RuntimeError('synthetic-token-in-url')), 'request_failed')
- def test_replace_preserves_signed_query_and_encodes_token(self):
- url = self.url + '&raw=%2f+%20&x=1&x=2&blank=&flag'
- updated = replace_token(url, 'synthetic+/&=?')
- self.assertEqual(updated, url.replace('synthetic-old', 'synthetic%2B%2F%26%3D%3F'))
- self.assertEqual(proxy_identity(updated)[1], 'synthetic+/&=?')
- self.assertIsNone(proxy_identity(url + '&proxyToken=duplicate'))
- def test_singleflight_and_origin_isolation(self):
- calls = []
- auth = ProxyTokens(lambda: self.client(lambda: calls.append('login')))
- barrier = threading.Barrier(8)
- def refresh(_):
- barrier.wait()
- auth.refresh(self.url)
- return auth.url(self.url)
- with ThreadPoolExecutor(max_workers=8) as pool:
- urls = list(pool.map(refresh, range(8)))
- self.assertEqual(calls, ['login'])
- self.assertEqual(set(urls), {self.url.replace('synthetic-old', 'synthetic-short')})
- for other in (self.url.replace('http:', 'https:'), self.url.replace(':10011', ':10012'),
- self.url.replace('example.invalid', 'other.invalid')):
- self.assertEqual(auth.url(other), other)
- self.assertEqual(repr(auth), '<ProxyTokens>')
- def test_failure_cooldown_and_later_recovery(self):
- now, calls = [10], []
- def factory():
- calls.append(1)
- if len(calls) == 1:
- raise RuntimeError('synthetic-password')
- return self.client()
- auth = ProxyTokens(factory, lambda: now[0])
- for _ in range(2):
- with self.assertRaisesRegex(ProxyAuthError, '^proxy_token_refresh_failed$'):
- auth.refresh(self.url)
- self.assertEqual(calls, [1])
- now[0] += 31
- auth.refresh(self.url)
- self.assertIn('synthetic-short', auth.url(self.url))
- def test_missing_login_same_or_malformed_token_fails_closed(self):
- for changes in ({'login_url': None}, {'token': 'synthetic-old'}, {'token': 'bad\nvalue'},
- {'token': ''}, {'token': None}, {'config': SimpleNamespace(username='', password='')}):
- with self.subTest(changes=changes):
- client = self.client()
- for name, value in changes.items():
- setattr(client, name, value)
- auth = ProxyTokens(lambda: client)
- with self.assertRaisesRegex(ProxyAuthError, '^proxy_token_refresh_failed$'):
- auth.refresh(self.url)
- self.assertEqual(auth.url(self.url), self.url)
- def test_fresh_rejection_cools_down_without_relogin(self):
- calls, now = [], [10]
- auth = ProxyTokens(lambda: self.client(lambda: calls.append(1)), lambda: now[0])
- auth.refresh(self.url)
- new = auth.url(self.url)
- auth.reject(new)
- with self.assertRaisesRegex(ProxyAuthError, '^proxy_token_refresh_failed$'):
- auth.refresh(new)
- self.assertEqual(calls, [1])
- class RecoveryHttpTests(unittest.TestCase):
- def setUp(self):
- self.calls, self.logins = [], []
- self.valid = 'synthetic-short'
- self.mode, self.login_mode, self.rejection_status = 'normal', 'normal', 200
- self.first_received = threading.Event()
- test = self
- class Handler(BaseHTTPRequestHandler):
- protocol_version = 'HTTP/1.1'
- def log_message(self, *_):
- pass
- def handle(self):
- try:
- super().handle()
- except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
- pass
- def envelope(self, value, status=200):
- body = json.dumps(value).encode()
- self.send_response(status)
- self.send_header('Content-Length', str(len(body)))
- self.end_headers()
- self.wfile.write(body)
- def do_POST(self):
- body = self.rfile.read(int(self.headers['Content-Length']))
- if self.path == '/oauth/api/user/login':
- test.logins.append((self.path, parse_qs(body.decode()), dict(self.headers)))
- if test.login_mode == 'redirect':
- self.send_response(302)
- self.send_header('Location', '/do-not-follow')
- self.send_header('Content-Length', '0')
- self.end_headers()
- else:
- self.envelope({'code': 201 if test.login_mode == 'denied' else 200,
- 'message': test.valid, 'content': None})
- return
- self.proxy(json.loads(body))
- def do_GET(self):
- self.proxy(None)
- def proxy(self, payload):
- query = parse_qs(urlsplit(self.path).query)
- test.calls.append((self.path, dict(self.headers), payload))
- if test.mode == 'permission':
- self.envelope({'code': 201})
- elif test.mode == 'upstream_auth':
- self.envelope({'Status': '115'})
- elif test.mode == 'http401':
- self.envelope({'message': 'unauthorized'}, 401)
- elif test.mode == 'http500':
- self.envelope({'code': 212}, 500)
- elif test.mode == 'truncated_rejection':
- body = b'{"code":212}'
- self.send_response(401)
- self.send_header('Content-Length', str(len(body) + 10))
- self.end_headers()
- self.wfile.write(body)
- self.close_connection = True
- elif test.mode == 'invalid_json':
- self.send_response(200)
- self.send_header('Content-Length', '1')
- self.end_headers()
- self.wfile.write(b'{')
- elif test.mode == 'always_invalid' or query['proxyToken'][0] != test.valid:
- if test.mode == 'whitespace' and payload is not None:
- self.send_response(200)
- self.send_header('Transfer-Encoding', 'chunked')
- self.end_headers()
- self.chunk(b' \n ')
- self.chunk(b'{"code":212}')
- self.wfile.write(b'0\r\n\r\n')
- else:
- self.envelope({'code': '212', 'message': 'synthetic rejection'}, test.rejection_status)
- elif payload is None:
- service = query['servertype'][0]
- company = {'KeyNo': 'synthetic-id', 'Name': '合成企业'}
- result = ([company] if service == 'fuzzySearch' else company
- if service == 'basicDetails' else {'VerifyResult': 0, 'Data': []})
- self.envelope({'Status': '200', 'Result': result})
- elif payload.get('stream'):
- self.send_response(200)
- self.send_header('Content-Type', 'text/event-stream')
- self.send_header('Transfer-Encoding', 'chunked')
- self.end_headers()
- self.chunk(b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n')
- if test.mode == 'partial':
- self.close_connection = True
- return
- if test.mode == 'incremental' and not test.first_received.wait(2):
- self.close_connection = True
- return
- self.chunk(b'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n')
- self.wfile.write(b'0\r\n\r\n')
- else:
- self.envelope({'choices': [{'message': {'content': 'OK'}}]})
- def chunk(self, body):
- self.wfile.write(f'{len(body):x}\r\n'.encode() + body + b'\r\n')
- self.wfile.flush()
- self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
- self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=.02), daemon=True)
- self.thread.start()
- self.temp = tempfile.TemporaryDirectory()
- self.path = Path(self.temp.name) / '.env'
- base = f'http://127.0.0.1:{self.server.server_port}'
- query = '?servertype={}&proxyToken=synthetic-old&raw=%2f+%20&x=1&x=2'
- lines = ['QCC_KEY=synthetic-key', 'QCC_SECRET_KEY=synthetic-secret', 'QCC_TIMEOUT_SECONDS=2',
- 'DEEPSEEK_API_KEY=synthetic-api-key', 'DEEPSEEK_MODEL=synthetic-model', 'SUMMARY_TIMEOUT_SECONDS=2',
- 'DMS_BASE_URL=http://127.0.0.1', f'AUTH_PORT={self.server.server_port}',
- f'DMS_PORT={self.server.server_port}', 'DMS_USERNAME=synthetic-user', 'DMS_PASSWORD=synthetic-password',
- 'DEEPSEEK_BASE_URL=' + base + '/proxy/chat/completions' + query.format('deepseek')]
- for name, service in (('SEARCH', 'fuzzySearch'), ('BASIC', 'basicDetails'), ('HONOR', 'honor')):
- lines.append('QCC_' + name + '_URL=' + base + '/proxy/' + query.format(service))
- self.path.write_text('\n'.join(lines), encoding='utf-8')
- self.original = self.path.read_bytes()
- self.env = patch.dict(os.environ, {'NO_PROXY': '*'}, clear=True)
- self.env.start()
- self.qconfig, self.mconfig = qcc.load_config(self.path), summarize.load_config(self.path)
- def tearDown(self):
- self.assertEqual(self.path.read_bytes(), self.original, '.env must stay byte-identical')
- self.env.stop()
- self.server.shutdown()
- self.server.server_close()
- self.thread.join()
- self.temp.cleanup()
- def model(self):
- return summarize.transport({'model': self.mconfig.model, 'messages': [{'role': 'user', 'content': 'synthetic'}]}, self.mconfig)
- def test_qcc_login_shared_with_other_interfaces_and_model(self):
- self.assertIs(self.qconfig.proxy_auth, self.mconfig.proxy_auth)
- client = qcc.QccClient(self.qconfig)
- company = client.search('合成企业')['candidates'][0]
- client.basic(company)
- self.assertTrue(client.honors(company)['complete'])
- self.assertEqual(self.model()['choices'][0]['message']['content'], 'OK')
- self.assertEqual(len(self.logins), 1)
- self.assertEqual(len(self.calls), 5)
- first, replay = self.calls[:2]
- self.assertEqual(first[0].replace('synthetic-old', 'synthetic-short'), replay[0])
- for name in ('Token', 'Timespan'):
- self.assertEqual(first[1][name], replay[1][name])
- expected = hashlib.md5(('synthetic-key' + replay[1]['Timespan'] + 'synthetic-secret').encode()).hexdigest().upper()
- self.assertEqual(replay[1]['Token'], expected)
- self.assertEqual(self.logins[0][1], {'userName': ['synthetic-user'], 'password': ['synthetic-password'],
- 'clientId': ['0'], 'serviceId': ['0']})
- self.assertNotIn('Token', self.logins[0][2])
- self.assertEqual(self.calls[-1][1]['Authorization'], 'Bearer synthetic-api-key')
- for path, headers, body in self.calls:
- self.assertIn('raw=%2f+%20&x=1&x=2', path)
- self.assertNotIn('synthetic-password', str(headers) + str(body) + path)
- def test_valid_long_token_needs_no_dms(self):
- self.valid = 'synthetic-old'
- with patch('src.step5_session_analysis.dms.load_dms_config', side_effect=AssertionError('no login expected')):
- self.model()
- qcc.QccClient(self.qconfig).search('合成企业')
- self.assertEqual(self.logins, [])
- def test_healthy_json_is_strictly_decoded_only_once(self):
- self.valid = 'synthetic-old'
- with patch.object(summarize, 'strict_json', wraps=summarize.strict_json) as decode:
- self.model()
- self.assertEqual(decode.call_count, 1)
- def test_model_json_replays_exact_payload_once(self):
- self.model()
- self.assertEqual(len(self.calls), 2)
- self.assertEqual(self.calls[0][2], self.calls[1][2])
- self.assertEqual(len(self.logins), 1)
- self.valid = 'synthetic-next'
- self.model()
- self.assertEqual(len(self.calls), 4)
- self.assertEqual(len(self.logins), 2)
- self.assertIn('proxyToken=synthetic-next', self.calls[-1][0])
- def test_stream_recovery_keeps_first_delta_incremental(self):
- self.mode = 'incremental'
- parts = []
- def consume(text):
- parts.append(text)
- self.first_received.set()
- result = stream_transport({'model': 'synthetic-model'}, self.mconfig, consume)
- self.assertEqual(parts, ['first', 'OK'])
- self.assertEqual(result['choices'][0]['message']['content'], 'firstOK')
- self.assertEqual(len(self.calls), 2)
- self.assertEqual(len(self.logins), 1)
- self.assertEqual(self.calls[0][2], self.calls[1][2])
- def test_whitespace_fragmented_rejection(self):
- self.mode = 'whitespace'
- self.assertEqual(self.model()['choices'][0]['message']['content'], 'OK')
- self.assertEqual(len(self.logins), 1)
- def test_http_auth_requires_explicit_212(self):
- for status in (401, 403):
- self.rejection_status = status
- self.valid = 'synthetic-' + str(status)
- self.model()
- self.assertIn('proxyToken=' + self.valid, self.calls[-1][0])
- self.assertEqual(len(self.logins), 2)
- # QCC uses the same outer rejection contract for HTTP auth failures.
- self.valid = 'synthetic-qcc'
- qcc.QccClient(self.qconfig).search('合成企业')
- self.assertEqual(len(self.logins), 3)
- def test_unrelated_failures_do_not_log_in(self):
- for mode, model_code, qcc_code in (
- ('permission', None, 'provider_failure'), ('upstream_auth', None, 'qcc_auth_failed'),
- ('http401', 'http_401', 'http_401'), ('http500', 'http_500', 'http_500'),
- ('invalid_json', 'invalid_response_json', 'invalid_json')):
- self.mode = mode
- if model_code is None:
- self.model() # Original provider schema validation belongs to the caller.
- else:
- with self.assertRaisesRegex(summarize.RequestFailure, '^' + model_code + '$'):
- self.model()
- with self.assertRaisesRegex(qcc.QccError, '^' + qcc_code + '$'):
- qcc.QccClient(self.qconfig).search('合成企业')
- self.assertEqual(self.logins, [])
- self.assertEqual(len(self.calls), 10)
- def test_second_rejection_stops_and_cools_down(self):
- self.mode = 'always_invalid'
- with self.assertRaisesRegex(summarize.RequestFailure, '^proxy_token_invalid$') as failure:
- self.model()
- self.assertFalse(failure.exception.retryable)
- self.assertEqual(len(self.calls), 2)
- with self.assertRaisesRegex(qcc.QccError, '^proxy_token_refresh_failed$'):
- qcc.QccClient(self.qconfig).search('合成企业')
- self.assertEqual(len(self.logins), 1)
- self.assertEqual(len(self.calls), 3)
- def test_truncated_auth_envelope_is_not_a_definite_rejection(self):
- self.mode = 'truncated_rejection'
- with self.assertRaisesRegex(summarize.RequestFailure, '^response_incomplete$'):
- self.model()
- with self.assertRaisesRegex(qcc.QccError, '^response_incomplete$'):
- qcc.QccClient(self.qconfig).search('合成企业')
- self.assertEqual(self.logins, [])
- self.assertEqual(len(self.calls), 2)
- def test_qcc_second_rejection_is_bounded(self):
- self.mode = 'always_invalid'
- with self.assertRaisesRegex(qcc.QccError, '^proxy_token_invalid$'):
- qcc.QccClient(self.qconfig).search('合成企业')
- self.assertEqual(len(self.calls), 2)
- self.assertEqual(len(self.logins), 1)
- def test_login_failure_and_redirect_are_safe_and_not_replayed(self):
- for mode in ('denied', 'redirect'):
- self.login_mode = mode
- # Isolate the two scenarios' shared caches using an explicit test env.
- config = summarize.load_config(self.path, {})
- with self.assertRaisesRegex(summarize.RequestFailure, '^proxy_token_refresh_failed$') as failure:
- summarize.transport({}, config)
- self.assertFalse(failure.exception.retryable)
- self.assertEqual(len(self.logins), 2)
- self.assertEqual(len(self.calls), 2)
- def test_partial_stream_is_never_replayed(self):
- self.valid, self.mode = 'synthetic-old', 'partial'
- parts = []
- with self.assertRaisesRegex(summarize.RequestFailure, '^response_incomplete$'):
- stream_transport({}, self.mconfig, parts.append)
- self.assertEqual(parts, ['first'])
- self.assertEqual(len(self.calls), 1)
- self.assertEqual(self.logins, [])
- def test_cancellation_after_login_prevents_replay(self):
- def check():
- if self.logins:
- raise summarize.RequestFailure('stream_cancelled', False)
- with self.assertRaisesRegex(summarize.RequestFailure, '^stream_cancelled$'):
- stream_transport({}, self.mconfig, lambda _: None, check)
- self.assertEqual(len(self.calls), 1)
- self.assertEqual(len(self.logins), 1)
- def test_direct_qcc_envelope_does_not_use_proxy_auth(self):
- config = replace(self.qconfig, search_url=qcc.DEFAULT_SEARCH_URL)
- with self.assertRaisesRegex(qcc.QccError, '^provider_failure$'):
- qcc.QccClient(config, send=lambda *_: {'code': 212}).search('合成企业')
- self.assertEqual(self.logins, [])
- if __name__ == '__main__':
- unittest.main()
|