Files
kefu/deploy/patient-session-20260918/test_patient_session_client.py
T
2026-09-21 10:34:06 +08:00

390 lines
23 KiB
Python

"""Patient reauthentication/keepalive use only synthetic HTTP and memory settings."""
import copy
import io
import json
import unittest
import urllib.error
from unittest import mock
import backend_client as client
class PatientHttpErrors(unittest.TestCase):
def request_error(self, status, body, path=None):
error = urllib.error.HTTPError('https://synthetic.invalid/path', status, 'synthetic', {},
io.BytesIO(json.dumps(body, ensure_ascii=False).encode('utf-8')))
with mock.patch.object(client.urllib.request, 'urlopen', side_effect=error):
try:
client._request('GET', 'https://synthetic.invalid', path or client.DESKTOP_PATIENT_SEARCH_PATH)
except client.BackendError as exc:
return exc
self.fail('expected synthetic HTTP error')
def test_structured_patient_expiry_preserves_type_code_and_message(self):
error = self.request_error(409, {'detail': {'code': 'zyt_session_expired', 'message': 'synthetic expired'}})
self.assertIsInstance(error, client.PatientSessionExpired)
self.assertNotIsInstance(error, client.AuthenticationError)
self.assertEqual(error.status, 409)
self.assertEqual(error.code, 'zyt_session_expired')
self.assertEqual(str(error), 'synthetic expired')
def test_exact_legacy_expiry_only_on_patient_routes(self):
for path in (client.DESKTOP_PATIENT_CONTEXT_PATH, client.DESKTOP_PATIENT_SEARCH_PATH + '?keyword=synthetic',
client.DESKTOP_PATIENT_BINDING_PATH):
with self.subTest(path=path):
self.assertIsInstance(self.request_error(502, {'detail': '登录超时,请重新登录'}, path),
client.PatientSessionExpired)
for path in (client.DESKTOP_ME_PATH, '/api/v2/model/calls'):
self.assertNotIsInstance(self.request_error(502, {'detail': '登录超时,请重新登录'}, path),
client.PatientSessionExpired)
def test_network_timeout_or_different_http_failure_is_not_patient_expiry(self):
for status, text in ((502, '网络请求超时'), (400, '登录超时,请重新登录'), (502, 'patient permission denied')):
error = self.request_error(status, {'detail': text})
self.assertNotIsInstance(error, client.PatientSessionExpired)
self.assertNotIsInstance(error, client.AuthenticationError)
def test_patient_permission_denied_is_not_desktop_revocation(self):
error = self.request_error(403, {'detail': {'code': 'permission_denied', 'message': 'synthetic denied'}})
self.assertEqual(error.status, 403)
self.assertEqual(error.code, 'permission_denied')
self.assertNotIsInstance(error, client.AuthenticationError)
self.assertNotIsInstance(error, client.PatientSessionExpired)
def test_desktop_revocation_stays_authentication_error(self):
error = self.request_error(401, {'detail': 'synthetic revoked'})
self.assertIsInstance(error, client.AuthenticationError)
self.assertNotIsInstance(error, client.PatientSessionExpired)
def test_structured_generic_error_is_readable(self):
error = self.request_error(409, {'detail': {'code': 'other_conflict', 'message': 'synthetic conflict'}})
self.assertEqual(str(error), 'synthetic conflict')
self.assertEqual(error.code, 'other_conflict')
class PatientSessionClientTests(unittest.TestCase):
def setUp(self):
self.store = {
'server_url': 'https://synthetic.invalid', 'zyt_api_url': 'https://identity.invalid',
'zyt_account': 'synthetic-admin', 'desktop_token_protected': 'sealed:desktop-original',
'zyt_token_protected': 'sealed:zyt-old', 'desktop_expires_at': 5000,
'desktop_account': {'id': 7}, 'remember_login': True,
'remembered_password_protected': 'sealed:remembered-original',
'model_plan': {'roles': {'judge_mode': 'shadow'}}, 'gateway': {'enabled': True},
'last_error': '',
}
self.saved = []
def save(settings):
self.store = copy.deepcopy(settings)
self.saved.append(copy.deepcopy(settings))
self.enterContext(mock.patch.object(client, 'load_settings', side_effect=lambda: copy.deepcopy(self.store)))
self.enterContext(mock.patch.object(client, 'save_settings', side_effect=save))
self.enterContext(mock.patch.object(client, '_protect_secret', side_effect=lambda value: 'sealed:' + value))
self.enterContext(mock.patch.object(client, '_unprotect_secret', side_effect=lambda value: str(value or '').removeprefix('sealed:')))
self.enterContext(mock.patch.object(client.time, 'time', return_value=1000))
self.enterContext(mock.patch.object(client, 'is_frozen', return_value=False))
self.enterContext(mock.patch.object(client.urllib.request, 'urlopen', side_effect=AssertionError('real network forbidden')))
self.request = self.enterContext(mock.patch.object(client, '_request', side_effect=self.response))
self.login = self.enterContext(mock.patch.object(client, '_zyt_login', return_value=('zyt-new', {'id': 7})))
self.exchange = self.enterContext(mock.patch.object(client, '_exchange_desktop_token', side_effect=AssertionError('desktop exchange forbidden')))
self.logout = self.enterContext(mock.patch.object(client, 'desktop_logout', side_effect=AssertionError('logout forbidden')))
self.keepalive_original = dict(client._PATIENT_KEEPALIVE_LAST)
client._PATIENT_KEEPALIVE_LAST.clear()
self.addCleanup(self.restore_keepalive)
self.identity_result = {'code': 1, 'data': {'status': 'active'}}
def restore_keepalive(self):
client._PATIENT_KEEPALIVE_LAST.clear()
client._PATIENT_KEEPALIVE_LAST.update(self.keepalive_original)
def response(self, method, server, path, **kwargs):
if path == client.DESKTOP_ME_PATH:
return 200, {'id': 7, 'expires_at': 9000}
if path == client.DESKTOP_AUTH_CONFIG_PATH:
return 200, {'zyt_api_url': 'https://identity.invalid', 'terminal': 7}
if path == client.DESKTOP_ZYT_SESSION_PATH:
return 200, {'ok': True}
if path == '/adminapi/desktop/session':
return 200, copy.deepcopy(self.identity_result)
raise AssertionError('unexpected synthetic route: ' + path)
def test_explicit_reauthentication_updates_only_upstream_token_after_server_ack(self):
result = client.reauthenticate_patient_session('typed-once')
self.assertEqual(result, {'ok': True})
self.login.assert_called_once_with('https://identity.invalid', 'synthetic-admin', 'typed-once')
route_call = next(call for call in self.request.call_args_list if call.args[2] == client.DESKTOP_ZYT_SESSION_PATH)
self.assertEqual(route_call.kwargs['token'], 'desktop-original')
self.assertEqual(route_call.kwargs['payload'], {'zyt_token': 'zyt-new'})
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-new')
self.assertEqual(self.store['desktop_token_protected'], 'sealed:desktop-original')
self.assertEqual(self.store['remembered_password_protected'], 'sealed:remembered-original')
self.assertNotIn('typed-once', repr(self.saved))
self.exchange.assert_not_called()
self.logout.assert_not_called()
def test_no_password_does_not_use_remembered_password_or_network(self):
with self.assertRaises(ValueError):
client.reauthenticate_patient_session('')
self.request.assert_not_called()
self.login.assert_not_called()
def test_desktop_revoked_prevents_upstream_login_and_any_exchange(self):
self.request.side_effect = client.AuthenticationError('synthetic revoked', status=401)
with self.assertRaises(client.AuthenticationError):
client.reauthenticate_patient_session('typed-once')
self.login.assert_not_called()
self.exchange.assert_not_called()
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-old')
def test_upstream_password_failure_does_not_logout_or_save_password(self):
self.login.side_effect = client.AuthenticationError('synthetic invalid password')
with self.assertRaises(client.AuthenticationError):
client.reauthenticate_patient_session('wrong')
self.assertFalse(any(call.args[2] == client.DESKTOP_ZYT_SESSION_PATH for call in self.request.call_args_list))
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-old')
self.logout.assert_not_called()
def test_server_rejects_new_credential_no_local_update(self):
def response(method, server, path, **kwargs):
if path == client.DESKTOP_ZYT_SESSION_PATH:
raise client.BackendError('synthetic account mismatch', status=403)
return self.response(method, server, path, **kwargs)
self.request.side_effect = response
with self.assertRaises(client.BackendError):
client.reauthenticate_patient_session('typed-once')
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-old')
self.assertEqual(self.store['desktop_token_protected'], 'sealed:desktop-original')
def test_session_switch_during_upstream_login_aborts_before_server_update(self):
def login(*args):
self.store.update(zyt_account='second-admin', desktop_token_protected='sealed:second-desktop',
zyt_token_protected='sealed:second-zyt', server_url='https://second.invalid')
return 'zyt-new', {}
self.login.side_effect = login
with self.assertRaises(client.BackendError) as caught:
client.reauthenticate_patient_session('typed-once')
self.assertEqual(caught.exception.code, 'session_changed')
self.assertFalse(any(call.args[2] == client.DESKTOP_ZYT_SESSION_PATH for call in self.request.call_args_list))
self.assertEqual(self.store['zyt_token_protected'], 'sealed:second-zyt')
def test_logout_during_server_update_cannot_restore_patient_token(self):
def response(method, server, path, **kwargs):
if path == client.DESKTOP_ZYT_SESSION_PATH:
self.store.update(desktop_token_protected='', zyt_token_protected='', desktop_account={})
return 200, {'ok': True}
return self.response(method, server, path, **kwargs)
self.request.side_effect = response
with self.assertRaises(client.BackendError):
client.reauthenticate_patient_session('typed-once')
self.assertEqual(self.store['zyt_token_protected'], '')
self.assertEqual(self.store['desktop_token_protected'], '')
def test_fetch_auth_config_without_persist_does_not_change_session(self):
previous = copy.deepcopy(self.store)
result = client.fetch_desktop_auth_config(self.store['server_url'], persist=False)
self.assertEqual(result['terminal'], 7)
self.assertEqual(self.store, previous)
self.assertFalse(self.saved)
def test_old_heartbeat_success_merges_without_overwriting_new_patient_token(self):
def response(*args, **kwargs):
self.store['zyt_token_protected'] = 'sealed:zyt-concurrently-new'
self.store['gateway'] = {'enabled': True, 'url': 'https://newgateway.invalid'}
return 200, {'id': 7, 'expires_at': 9500}
self.request.side_effect = response
client.validate_desktop_session()
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-concurrently-new')
self.assertEqual(self.store['gateway']['url'], 'https://newgateway.invalid')
self.assertEqual(self.store['desktop_expires_at'], 9500)
def test_old_heartbeat_network_error_preserves_new_patient_token(self):
def response(*args, **kwargs):
self.store['zyt_token_protected'] = 'sealed:zyt-concurrently-new'
raise client.BackendError('synthetic network timeout')
self.request.side_effect = response
with self.assertRaises(client.BackendError):
client.validate_desktop_session()
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-concurrently-new')
def test_old_heartbeat_after_logout_or_new_login_cannot_revive_old_session(self):
for error in (None, client.AuthenticationError('synthetic old revoked', status=401)):
with self.subTest(error=error is not None):
self.store['desktop_token_protected'] = 'sealed:desktop-before'
def response(*args, **kwargs):
self.store.update(desktop_token_protected='sealed:desktop-after', desktop_account={'id': 88})
if error:
raise error
return 200, {'id': 7, 'expires_at': 9500}
self.request.side_effect = response
with self.assertRaises(client.BackendError) as caught:
client.validate_desktop_session()
self.assertNotIsInstance(caught.exception, client.AuthenticationError)
self.assertEqual(caught.exception.code, 'session_changed')
self.assertEqual(self.store['desktop_account']['id'], 88)
self.assertEqual(self.store['desktop_token_protected'], 'sealed:desktop-after')
def test_patient_search_and_bind_preserve_expiry_type(self):
self.request.side_effect = client.PatientSessionExpired(status=409)
for operation in (lambda: client.search_desktop_patients('synthetic'),
lambda: client.bind_desktop_patient('account', 'conv', 1)):
with self.assertRaises(client.PatientSessionExpired):
operation()
self.login.assert_not_called()
self.logout.assert_not_called()
def test_keepalive_uses_original_custom_token_without_login_or_exchange(self):
result = client.keepalive_zyt_session()
self.assertEqual(result, {'status': 'active'})
call = next(call for call in self.request.call_args_list if call.args[2] == '/adminapi/desktop/session')
self.assertEqual(call.args[:2], ('GET', 'https://identity.invalid'))
self.assertTrue(call.kwargs['identity_service'])
self.assertEqual(call.kwargs['extra_headers'], {'token': 'zyt-old'})
self.assertNotIn('token', call.kwargs)
self.login.assert_not_called()
self.exchange.assert_not_called()
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-old')
def test_keepalive_expiry_is_local_status_not_forced_logout(self):
self.identity_result = {'code': -1, 'msg': '登录超时,请重新登录'}
self.assertEqual(client.keepalive_zyt_session()['status'], 'expired')
self.logout.assert_not_called()
self.login.assert_not_called()
self.assertEqual(self.store['desktop_token_protected'], 'sealed:desktop-original')
def test_keepalive_permission_network_and_missing_route_are_distinct(self):
cases = [(client.AuthenticationError('synthetic denied', status=403), 'forbidden'),
(client.AuthenticationError('synthetic expired', status=401), 'expired'),
(client.BackendError('synthetic offline'), 'unreachable'),
(client.BackendError('synthetic not deployed', status=404), 'unsupported')]
for failure, expected in cases:
with self.subTest(expected=expected):
client._PATIENT_KEEPALIVE_LAST.clear()
def response(method, server, path, **kwargs):
if path == '/adminapi/desktop/session':
raise failure
return self.response(method, server, path, **kwargs)
self.request.side_effect = response
self.assertEqual(client.keepalive_zyt_session()['status'], expected)
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-old')
self.logout.assert_not_called()
def test_keepalive_requires_valid_desktop_before_upstream_request(self):
self.request.side_effect = client.AuthenticationError('synthetic desktop revoked', status=401)
self.assertEqual(client.keepalive_zyt_session()['status'], 'desktop_invalid')
self.assertEqual(len(self.request.call_args_list), 1)
self.assertEqual(self.request.call_args.args[2], client.DESKTOP_ME_PATH)
self.login.assert_not_called()
def test_keepalive_throttles_same_credentials_for_thirty_minutes(self):
with mock.patch.object(client.time, 'monotonic', side_effect=[100, 101, 1900]):
self.assertEqual(client.keepalive_zyt_session()['status'], 'active')
self.assertEqual(client.keepalive_zyt_session()['reason'], 'throttled')
self.assertEqual(client.keepalive_zyt_session()['status'], 'active')
self.assertEqual(sum(call.args[2] == '/adminapi/desktop/session' for call in self.request.call_args_list), 2)
def test_keepalive_in_progress_does_not_start_another_request(self):
client._PATIENT_KEEPALIVE_LOCK.acquire()
try:
self.assertEqual(client.keepalive_zyt_session()['reason'], 'in_progress')
self.request.assert_not_called()
finally:
client._PATIENT_KEEPALIVE_LOCK.release()
def test_old_keepalive_cannot_report_new_credential_expired(self):
def response(method, server, path, **kwargs):
if path == '/adminapi/desktop/session':
self.store['zyt_token_protected'] = 'sealed:zyt-concurrently-new'
return 200, {'code': -1, 'msg': '登录超时,请重新登录'}
return self.response(method, server, path, **kwargs)
self.request.side_effect = response
self.assertEqual(client.keepalive_zyt_session(), {'status': 'skipped', 'reason': 'session_changed'})
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-concurrently-new')
def test_new_patient_token_is_not_throttled_by_old_expired_token(self):
self.identity_result = {'code': -1, 'msg': '登录超时,请重新登录'}
self.assertEqual(client.keepalive_zyt_session()['status'], 'expired')
self.store['zyt_token_protected'] = 'sealed:zyt-new'
self.identity_result = {'code': 1}
self.assertEqual(client.keepalive_zyt_session()['status'], 'active')
def test_keepalive_exact_deployed_expiry_message(self):
self.identity_result = {'code': 0, 'msg': '登录已失效,请重新登录'}
self.assertEqual(client.keepalive_zyt_session()['status'], 'expired')
self.logout.assert_not_called()
self.login.assert_not_called()
def test_periodic_config_success_does_not_restore_stale_patient_credentials(self):
snapshot = copy.deepcopy(self.store)
self.store['zyt_token_protected'] = 'sealed:zyt-concurrently-new'
self.store['remembered_password_protected'] = 'sealed:new-remembered'
with mock.patch('ai_config.apply_settings', return_value={}):
client._apply_config_response({'version': 4, 'config': {},
'gateway': {'url': 'https://gateway.invalid'}, 'roles': {}, 'models': []}, snapshot)
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-concurrently-new')
self.assertEqual(self.store['remembered_password_protected'], 'sealed:new-remembered')
self.assertEqual(self.store['last_version'], 4)
self.assertEqual(self.store['gateway']['url'], 'https://gateway.invalid')
def test_periodic_config_for_old_session_is_not_applied_after_account_change(self):
snapshot = copy.deepcopy(self.store)
self.store['desktop_token_protected'] = 'sealed:new-desktop'
with mock.patch('ai_config.apply_settings') as apply:
with self.assertRaises(client.BackendError):
client._apply_config_response({'version': 4, 'config': {}}, snapshot)
apply.assert_not_called()
self.assertEqual(self.store['desktop_token_protected'], 'sealed:new-desktop')
def test_periodic_config_failure_merges_only_error_without_old_token(self):
def fetch(*args):
self.store['zyt_token_protected'] = 'sealed:zyt-concurrently-new'
raise client.BackendError('synthetic config timeout')
with mock.patch.object(client, '_fetch_desktop_config', side_effect=fetch):
with self.assertRaises(client.BackendError):
client.sync_cloud_config()
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-concurrently-new')
self.assertEqual(self.store['last_error'], 'synthetic config timeout')
def test_old_exchange_after_logout_cannot_revive_desktop_or_return_old_token(self):
self.store['desktop_expires_at'] = 0
def exchange(*args, **kwargs):
self.store.update(desktop_token_protected='', zyt_token_protected='', desktop_account={})
return {'access_token': 'stale-response', 'account': {'id': 7}, 'expires_in': 5000}
self.exchange.side_effect = exchange
self.assertEqual(client.desktop_access_token(), '')
self.assertEqual(self.store['desktop_token_protected'], '')
self.assertEqual(self.store['zyt_token_protected'], '')
def test_refreshing_desktop_token_preserves_concurrent_new_zyt_token(self):
self.store['desktop_expires_at'] = 0
def exchange(*args, **kwargs):
self.store['zyt_token_protected'] = 'sealed:zyt-concurrently-new'
return {'access_token': 'new-desktop', 'account': {'id': 7}, 'expires_in': 5000}
self.exchange.side_effect = exchange
self.assertEqual(client.desktop_access_token(), 'new-desktop')
self.assertEqual(self.store['zyt_token_protected'], 'sealed:zyt-concurrently-new')
self.assertEqual(self.store['desktop_token_protected'], 'sealed:new-desktop')
def test_restore_ignores_late_revocation_from_previous_session(self):
def validate(**kwargs):
self.store.update(desktop_token_protected='sealed:second-desktop', zyt_token_protected='sealed:second-zyt')
raise client.AuthenticationError('synthetic previous session revoked', status=401)
with mock.patch.object(client, 'validate_desktop_session', side_effect=validate):
self.assertIsNone(client.restore_desktop_session())
self.assertEqual(self.store['desktop_token_protected'], 'sealed:second-desktop')
self.assertEqual(self.store['zyt_token_protected'], 'sealed:second-zyt')
def test_patient_request_never_pairs_old_token_with_new_server(self):
def get_token():
self.store.update(server_url='https://second.invalid', desktop_token_protected='sealed:second-desktop')
return 'desktop-original'
with mock.patch.object(client, 'desktop_access_token', side_effect=get_token):
with self.assertRaises(client.BackendError) as caught:
client.search_desktop_patients('synthetic')
self.assertEqual(caught.exception.code, 'session_changed')
self.request.assert_not_called()
if __name__ == '__main__':
unittest.main()