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

271 lines
15 KiB
Python

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