"""Patient reauthentication must not replace or revive a desktop login.""" import io import json import tempfile import urllib.error from pathlib import Path from unittest import TestCase, mock from fastapi.testclient import TestClient import admin_api import admin_backend as backend import archive_api import zyt_auth import zyt_patient_client as patient class PatientErrorClassificationTest(TestCase): def response(self, payload): response = mock.MagicMock() response.__enter__.return_value.read.return_value = json.dumps(payload).encode() return response def test_upstream_http_401_is_recoverable_session_expiry(self): error = urllib.error.HTTPError('https://audit.invalid', 401, 'Unauthorized', {}, io.BytesIO()) with mock.patch('urllib.request.urlopen', side_effect=error), self.assertRaises(patient.ZytPatientSessionExpired): patient.ZytPatientClient('https://audit.invalid', 'synthetic').search_patients('合成测试') def test_upstream_http_403_is_permission_denial(self): error = urllib.error.HTTPError('https://audit.invalid', 403, 'Forbidden', {}, io.BytesIO()) with mock.patch('urllib.request.urlopen', side_effect=error), self.assertRaises(patient.ZytPatientPermissionDenied): patient.ZytPatientClient('https://audit.invalid', 'synthetic').search_patients('合成测试') def test_upstream_expiry_codes_and_messages_are_classified(self): for payload in ({'code': -1, 'msg': '会话无效'}, {'code': 0, 'msg': '登录超时,请重新登录'}, {'code': 0, 'msg': '登录已失效,请重新登录'}): with self.subTest(payload=payload), mock.patch('urllib.request.urlopen', return_value=self.response(payload)), self.assertRaises(patient.ZytPatientSessionExpired): patient.ZytPatientClient('https://audit.invalid', 'synthetic').search_patients('合成测试') def test_upstream_permission_message_is_not_reclassified_as_expiry(self): for code in (0, -1): with self.subTest(code=code), mock.patch('urllib.request.urlopen', return_value=self.response({'code': code, 'msg': '权限不足,无法访问或操作'})), self.assertRaises(patient.ZytPatientPermissionDenied): patient.ZytPatientClient('https://audit.invalid', 'synthetic').search_patients('合成测试') def test_bad_application_response_stays_external_error(self): with mock.patch('urllib.request.urlopen', return_value=self.response({'code': 'bad', 'msg': '查询失败'})), self.assertRaises(patient.ZytPatientError) as raised: patient.ZytPatientClient('https://audit.invalid', 'synthetic').search_patients('合成测试') self.assertNotIsInstance(raised.exception, patient.ZytPatientSessionExpired) def test_api_uses_distinct_statuses(self): expired = archive_api._http_error(patient.ZytPatientSessionExpired('original')) self.assertEqual(expired.status_code, 409) self.assertEqual(expired.detail, {'code': 'zyt_session_expired', 'message': 'ZYT 患者查询登录已过期,请重新验证账号。'}) self.assertEqual(archive_api._http_error(patient.ZytPatientPermissionDenied('无权')).status_code, 403) self.assertEqual(archive_api._http_error(patient.ZytPatientError('网络失败')).status_code, 502) class DesktopZytSessionRecoveryTest(TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='patient-session-audit-') self.addCleanup(self.temp.cleanup) self.path = Path(self.temp.name) / 'audit.db' self.database = backend.Database(self.path) self.database.initialize('InitialAudit123') self.before_verify = None self.verified = [] self.searcher = mock.Mock(return_value={'items': [], 'total': 0, 'page_no': 1, 'page_size': 20}) self.app = admin_api.create_app(self.path, zyt_token_verifier=self.verify, zyt_patient_searcher=self.searcher) self.client = TestClient(self.app, raise_server_exceptions=False) self.addCleanup(self.client.close) login = self.client.post('/api/v2/desktop/auth/exchange', headers={'Authorization': 'Bearer original-zyt'}, json={'device_id': 'audit-device-001', 'interactive_login': True}) self.assertEqual(login.status_code, 200, login.text) self.token = login.json()['access_token'] self.headers = {'Authorization': 'Bearer ' + self.token} self.account_id = login.json()['account']['id'] self.tenant = login.json()['account']['tenant_id'] self.store = self.app.state.archive_store def verify(self, token): self.verified.append(token) if self.before_verify: self.before_verify() if token == 'expired-zyt': raise zyt_auth.ZytAuthError('登录已失效') return {'user_id': 'admin:other' if token == 'other-zyt' else 'admin:audit', 'sn': 'audit', 'nickname': '合成测试', 'terminal': 7, 'status': 'active', 'root': False, 'role_ids': [3, 3], 'permissions': [] if token == 'restricted-zyt' else ['tcm.diagnosis/lists'], 'permissions_known': True} def refresh(self, token='fresh-zyt', headers=None): return self.client.post('/api/v2/desktop/auth/zyt-session', headers=self.headers if headers is None else headers, json={'zyt_token': token}) def snapshot(self): with self.database.connect() as db: return { 'sessions': [tuple(r) for r in db.execute('SELECT id,account_id,device_row_id,token_digest,revoked_at FROM desktop_sessions ORDER BY id')], 'devices': [tuple(r) for r in db.execute('SELECT id,account_id,device_id,revoked_at FROM desktop_devices ORDER BY id')], 'account': tuple(db.execute('SELECT id,zyt_user_id,tenant_id,zyt_root,zyt_permissions_json,zyt_role_ids_json,zyt_permissions_known FROM desktop_accounts WHERE id=?', (self.account_id,)).fetchone()), 'stored': tuple(db.execute('SELECT tenant_id,zyt_user_id,token_enc FROM archive_zyt_session WHERE tenant_id=?', (self.tenant,)).fetchone() or ()), } def search(self): return self.client.get('/api/v2/archive/desktop/patients/search', headers=self.headers, params={'keyword': '合成测试'}) def test_same_account_refresh_keeps_desktop_token_session_device_and_tenant(self): before = self.snapshot() response = self.refresh() self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json(), {'ok': True}) after = self.snapshot() self.assertEqual(after['sessions'], before['sessions']) self.assertEqual(after['devices'], before['devices']) self.assertEqual(after['account'][:3], before['account'][:3]) self.assertEqual(self.store.zyt_token(self.tenant), 'fresh-zyt') self.assertNotIn('fresh-zyt', after['stored'][2]) self.assertEqual(self.client.get('/api/v2/desktop/me', headers=self.headers).status_code, 200) self.assertEqual(self.search().status_code, 200) self.assertEqual(self.searcher.call_args.args[0], 'fresh-zyt') def test_other_account_is_rejected_without_changing_credentials_or_permissions(self): before = self.snapshot() response = self.refresh('other-zyt') self.assertEqual(response.status_code, 403, response.text) self.assertEqual(self.snapshot(), before) def test_zyt_rejection_keeps_desktop_session_valid(self): before = self.snapshot() response = self.refresh('expired-zyt') self.assertEqual(response.status_code, 409, response.text) self.assertEqual(response.json()['detail']['code'], 'zyt_session_expired') self.assertEqual(self.snapshot(), before) self.assertEqual(self.client.get('/api/v2/desktop/me', headers=self.headers).status_code, 200) def actual_verifier(self): self.before_verify = lambda: zyt_auth.verify_desktop_token('fresh-zyt', api_url='https://audit.invalid') def assert_unavailable_preserves_session(self, response, before): self.assertEqual(response.status_code, 502, response.text) self.assertEqual(response.json()['detail']['code'], 'zyt_unavailable') self.assertEqual(self.snapshot(), before) self.assertEqual(self.client.get('/api/v2/desktop/me', headers=self.headers).status_code, 200) def test_identity_network_failure_is_unavailable_not_expiry(self): self.actual_verifier() before = self.snapshot() for error in (urllib.error.URLError('synthetic connection failure'), TimeoutError('synthetic timeout')): with self.subTest(error=type(error).__name__), mock.patch('urllib.request.urlopen', side_effect=error): self.assert_unavailable_preserves_session(self.refresh(), before) def test_identity_server_failure_is_unavailable_not_expiry(self): self.actual_verifier() before = self.snapshot() for code in (500, 502, 503): error = urllib.error.HTTPError('https://audit.invalid', code, 'Synthetic failure', {}, io.BytesIO()) with self.subTest(code=code), mock.patch('urllib.request.urlopen', side_effect=error): self.assert_unavailable_preserves_session(self.refresh(), before) def test_identity_invalid_response_is_unavailable_not_expiry(self): self.actual_verifier() before = self.snapshot() for body in (b'not-json', b'[]', b'{"code":1,"data":{}}'): response = mock.MagicMock() response.__enter__.return_value.read.return_value = body with self.subTest(body=body), mock.patch('urllib.request.urlopen', return_value=response): self.assert_unavailable_preserves_session(self.refresh(), before) def test_identity_http_401_and_expired_payload_are_recoverable(self): self.actual_verifier() before = self.snapshot() error = urllib.error.HTTPError('https://audit.invalid', 401, 'Unauthorized', {}, io.BytesIO()) with mock.patch('urllib.request.urlopen', side_effect=error): response = self.refresh() self.assertEqual(response.status_code, 409, response.text) self.assertEqual(response.json()['detail']['code'], 'zyt_session_expired') upstream = mock.MagicMock() upstream.__enter__.return_value.read.return_value = json.dumps({'code': 0, 'msg': '登录已失效,请重新登录'}).encode() with mock.patch('urllib.request.urlopen', return_value=upstream): response = self.refresh() self.assertEqual(response.status_code, 409, response.text) self.assertEqual(response.json()['detail']['code'], 'zyt_session_expired') self.assertEqual(self.snapshot(), before) def test_identity_permission_rejection_remains_403(self): self.actual_verifier() before = self.snapshot() error = urllib.error.HTTPError('https://audit.invalid', 403, 'Forbidden', {}, io.BytesIO()) with mock.patch('urllib.request.urlopen', side_effect=error): response = self.refresh() self.assertEqual(response.status_code, 403, response.text) self.assertIn('无权', response.json()['detail']) self.assertEqual(self.snapshot(), before) def test_revoked_desktop_is_rejected_before_upstream_validation(self): self.database.revoke_desktop_session(self.token) self.verified.clear() before = self.snapshot() response = self.refresh() self.assertEqual(response.status_code, 401, response.text) self.assertEqual(self.verified, []) self.assertEqual(self.snapshot(), before) def test_revocation_during_upstream_validation_prevents_refresh(self): self.before_verify = lambda: self.database.revoke_desktop_session(self.token) before = self.snapshot() response = self.refresh() self.assertEqual(response.status_code, 401, response.text) after = self.snapshot() self.assertEqual(after['stored'], before['stored']) self.assertEqual(after['account'], before['account']) self.assertTrue(after['sessions'][0][-1]) def test_account_disabled_during_verification_prevents_refresh(self): def disable(): with self.database.connect() as db: db.execute("UPDATE desktop_accounts SET status='disabled' WHERE id=?", (self.account_id,)) db.commit() self.before_verify = disable response = self.refresh() self.assertEqual(response.status_code, 401, response.text) self.assertEqual(self.store.zyt_token(self.tenant), 'original-zyt') def test_revoked_device_during_verification_prevents_refresh(self): def revoke(): with self.database.connect() as db: db.execute("UPDATE desktop_devices SET revoked_at='audit-revoked' WHERE account_id=?", (self.account_id,)) db.commit() self.before_verify = revoke response = self.refresh() self.assertEqual(response.status_code, 401, response.text) self.assertEqual(self.store.zyt_token(self.tenant), 'original-zyt') def test_permissions_are_refreshed_and_query_is_denied_immediately(self): response = self.refresh('restricted-zyt') self.assertEqual(response.status_code, 200, response.text) queried = self.search() self.assertEqual(queried.status_code, 403, queried.text) self.searcher.assert_not_called() account = self.snapshot()['account'] self.assertEqual(json.loads(account[4]), []) self.assertEqual(json.loads(account[5]), [3]) self.assertEqual(account[6], 1) self.assertEqual(self.refresh('fresh-zyt').status_code, 200) self.assertEqual(self.search().status_code, 200) def test_missing_upstream_session_is_recoverable(self): self.store.clear_zyt_session(self.tenant) response = self.search() self.assertEqual(response.status_code, 409, response.text) self.assertEqual(response.json()['detail']['code'], 'zyt_session_expired') self.assertEqual(self.refresh().status_code, 200) self.assertEqual(self.search().status_code, 200) def test_expired_upstream_search_does_not_break_archive_authentication(self): self.searcher.side_effect = patient.ZytPatientSessionExpired('登录超时,请重新登录') response = self.search() self.assertEqual(response.status_code, 409, response.text) self.assertEqual(response.json()['detail']['code'], 'zyt_session_expired') self.assertEqual(self.client.get('/api/v2/desktop/me', headers=self.headers).status_code, 200) def test_permission_failure_is_403_instead_of_reauthentication_loop(self): self.searcher.side_effect = patient.ZytPatientPermissionDenied('权限不足,无法访问或操作') response = self.search() self.assertEqual(response.status_code, 403, response.text) self.assertIn('权限', response.json()['detail']) def test_permissions_and_credentials_roll_back_together_on_storage_failure(self): before = self.snapshot() with self.database.connect() as db: db.execute("CREATE TRIGGER audit_fail BEFORE INSERT ON archive_zyt_session BEGIN SELECT RAISE(ABORT,'synthetic write failure'); END") db.commit() response = self.refresh('restricted-zyt') self.assertEqual(response.status_code, 500) self.assertEqual(self.snapshot(), before)