"""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()