271 lines
15 KiB
Python
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)
|