test_dms_connection.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import contextlib
  2. import io
  3. import json
  4. import os
  5. from pathlib import Path
  6. import socket
  7. import tempfile
  8. import unittest
  9. from unittest.mock import patch
  10. from urllib.error import URLError, HTTPError
  11. from scripts import test_dms_connection as probe
  12. from src.dms_schema import DEFAULT_SCHEMA
  13. class DiagnosticTests(unittest.TestCase):
  14. def test_compact_output_keeps_failures_and_hides_matched_fields(self):
  15. self.mismatch = True
  16. with patch('urllib.request.OpenerDirector.open', side_effect=self.fake), contextlib.redirect_stderr(io.StringIO()):
  17. report, _ = probe.run(self.root, ('records', 'honor'), env_file_only=True)
  18. short = probe.compact_report(report, 2)
  19. self.assertEqual(short['passed_columns'], 1)
  20. self.assertEqual(short['failed_columns'], 1)
  21. self.assertNotIn('honor', short['failed_checks'])
  22. self.assertEqual({f['status'] for f in short['failed_checks']['records']['fields']}, {'missing', 'type_mismatch'})
  23. self.assertEqual(len(short['related_requests']), 1)
  24. self.assertIn('getModelById', short['related_requests'][0]['endpoint'])
  25. early = probe.compact_report({'ok': False, 'stage': 'authentication', 'code': 'dms_login_failed',
  26. 'checks': {}, 'requests': [{'http_status': 404, 'error': 'dms_http_error'}]}, 5)
  27. self.assertEqual(early['not_checked_columns'], 5)
  28. self.assertEqual(early['related_requests'][0]['http_status'], 404)
  29. def test_cli_default_quiet_and_verbose_opt_in(self):
  30. def fake_run(*args, **kwargs):
  31. probe.emit('request_start', {'endpoint': 'synthetic'})
  32. return {'ok': True, 'checks': {'records': {'ok': True, 'fields': [{'status': 'matched'}]}}, 'requests': []}, set()
  33. for verbose in (False, True):
  34. out, err = io.StringIO(), io.StringIO()
  35. argv = ['test_dms_connection.py', '--column', 'records'] + (['--verbose'] if verbose else [])
  36. with patch.object(probe, 'run', side_effect=fake_run), patch('sys.argv', argv), \
  37. contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
  38. self.assertEqual(probe.main(), 0)
  39. result = json.loads(out.getvalue())
  40. if verbose:
  41. self.assertIn('request_start', err.getvalue())
  42. self.assertIn('checks', result)
  43. else:
  44. self.assertEqual(err.getvalue(), '')
  45. self.assertEqual(result, {'ok': True, 'checked_columns': 1, 'passed_columns': 1,
  46. 'failed_columns': 0, 'not_checked_columns': 0})
  47. def test_command_line_overrides_with_old_config_without_login_url(self):
  48. from types import SimpleNamespace
  49. original_loader = probe.load_dms_config
  50. def legacy_loader(*args, **kwargs):
  51. config = original_loader(*args, **kwargs)
  52. values = {k: v for k, v in vars(config).items() if k != 'login_url'}
  53. return SimpleNamespace(**values, check_scope=config.check_scope, alias_for=config.alias_for)
  54. def fake(request, timeout):
  55. if request.full_url == 'http://auth.invalid:8888/oauth/api/user/login':
  56. return Response({'code': 200, 'message': 'new-private-token'})
  57. self.assertTrue(request.full_url.startswith('http://data.invalid:10081/dms/'))
  58. return self.fake(request, timeout)
  59. (self.root / '.env').write_text('DMS_BASE_URL=http://localhost\nAUTH_PORT=9999\nDMS_PORT=9998\n'
  60. 'DMS_USERNAME=private-user\nDMS_PASSWORD=private-password\n')
  61. original = (self.root / '.env').read_bytes()
  62. with patch.object(probe, 'load_dms_config', side_effect=legacy_loader), \
  63. patch('src.step5_session_analysis.dms.connection_urls',
  64. return_value=('http://localhost:9998/proxy_dms', 'http://localhost:9999/proxy_oauth/user/login')), \
  65. patch('urllib.request.OpenerDirector.open', side_effect=fake), contextlib.redirect_stderr(io.StringIO()):
  66. report, _ = probe.run(self.root, ('records',), env_file_only=True,
  67. api_url='http://data.invalid:10081/dms', login_url='http://auth.invalid:8888/oauth/api/user/login')
  68. self.assertTrue(report['ok'], report)
  69. self.assertEqual(report['login_url_source'], 'command_line')
  70. self.assertEqual((self.root / '.env').read_bytes(), original)
  71. def test_invalid_override_never_sends_request(self):
  72. for url in ('http://user:secret@invalid/login', 'http://invalid/login?token=secret',
  73. 'http://invalid:99999/login', 'http://invalid/a b', 'relative', ''):
  74. with patch('urllib.request.OpenerDirector.open') as send, contextlib.redirect_stderr(io.StringIO()):
  75. report, _ = probe.run(self.root, ('records',), login_url=url)
  76. self.assertEqual(report['code'], 'invalid_endpoint_override')
  77. send.assert_not_called()
  78. def setUp(self):
  79. self.temp = tempfile.TemporaryDirectory()
  80. self.addCleanup(self.temp.cleanup)
  81. self.root = Path(self.temp.name)
  82. (self.root / 'config').mkdir()
  83. self.schema = json.loads(json.dumps(DEFAULT_SCHEMA))
  84. (self.root / 'config/dms_schema.json').write_text(json.dumps(self.schema), encoding='utf-8')
  85. (self.root / '.env').write_text('DMS_BASE_URL=http://localhost:1234\nDMS_TOKEN=synthetic-private-token\n', encoding='utf-8')
  86. self.forms = []
  87. self.mismatch = False
  88. def fake(self, request, timeout):
  89. from urllib.parse import parse_qs
  90. form = parse_qs(request.data.decode())
  91. self.forms.append((request.full_url, form))
  92. if request.full_url.endswith('getColumnById'):
  93. identity = int(form['id'][0])
  94. spec = next((v for v in self.schema['columns'].values() if v['id'] == identity), None)
  95. content = {'title': spec['name'], 'type': 1, 'parentId': 999, 'modelId': identity} if spec else {'title': self.schema['parent']}
  96. else:
  97. spec = next(v for v in self.schema['columns'].values() if v['id'] == int(form['modelId'][0]))
  98. raw = {actual: {'type': spec.get('types', {}).get(logical, 'text')} for logical, actual in spec['fields'].items()}
  99. if self.mismatch:
  100. raw.pop('c_question', None)
  101. if 'c_created_at' in raw:
  102. raw['c_created_at']['type'] = 'text'
  103. content = {'fieldList': json.dumps(raw)}
  104. return Response({'code': 200, 'content': content})
  105. def test_all_columns_requests_and_preflight_logs(self):
  106. logs = io.StringIO()
  107. with patch('urllib.request.OpenerDirector.open', side_effect=self.fake), contextlib.redirect_stderr(logs):
  108. report, secrets = probe.run(self.root, probe.ALIASES, env_file_only=True)
  109. self.assertTrue(report['ok'], report)
  110. self.assertEqual(len(self.forms), 15)
  111. self.assertTrue(all('/content/' not in url for url, _ in self.forms))
  112. self.assertIn('connection_parameters', logs.getvalue())
  113. self.assertIn('column_start', logs.getvalue())
  114. self.assertIn('"id":', logs.getvalue())
  115. self.assertNotIn('synthetic-private-token', logs.getvalue() + probe.redact_report(report, secrets))
  116. def test_missing_and_wrong_field_types_are_specific(self):
  117. self.mismatch = True
  118. with patch('urllib.request.OpenerDirector.open', side_effect=self.fake), contextlib.redirect_stderr(io.StringIO()):
  119. report, _ = probe.run(self.root, ('records',), env_file_only=True)
  120. check = report['checks']['records']
  121. self.assertFalse(check['ok'])
  122. fields = {v['logical_field']: v for v in check['fields']}
  123. self.assertEqual(fields['c_question']['status'], 'missing')
  124. self.assertEqual(fields['c_created_at']['status'], 'type_mismatch')
  125. self.assertEqual(fields['c_created_at']['expected_type'], 'timestamp')
  126. def test_scope_rejection_makes_no_requests(self):
  127. with (self.root / '.env').open('a') as f:
  128. f.write('DMS_STRICT_SCOPE=true\nDMS_ALLOWED_SCOPES_JSON={}\n')
  129. with patch('urllib.request.OpenerDirector.open') as send, contextlib.redirect_stderr(io.StringIO()):
  130. report, _ = probe.run(self.root, ('records',), env_file_only=True)
  131. self.assertEqual(report['code'], 'dms_scope_denied')
  132. send.assert_not_called()
  133. def test_network_http_and_auth_failures_keep_diagnostics(self):
  134. for error, expected in [(URLError(socket.gaierror(-2, 'secret-error')), 'dns_failed'),
  135. (URLError(ConnectionRefusedError(111, 'secret-error')), 'connection_refused'),
  136. (URLError(TimeoutError('secret-error')), 'timeout')]:
  137. with patch('urllib.request.OpenerDirector.open', side_effect=error), contextlib.redirect_stderr(io.StringIO()):
  138. report, _ = probe.run(self.root, ('records',), env_file_only=True)
  139. self.assertEqual(report['requests'][0]['error'], expected)
  140. self.assertNotIn('secret-error', json.dumps(report))
  141. error = HTTPError('http://localhost', 404, 'secret-error', {}, io.BytesIO(b'secret-body'))
  142. with patch('urllib.request.OpenerDirector.open', side_effect=error), contextlib.redirect_stderr(io.StringIO()):
  143. report, _ = probe.run(self.root, ('records',), env_file_only=True)
  144. self.assertEqual(report['requests'][0]['http_status'], 404)
  145. self.assertNotIn('secret-body', json.dumps(report))
  146. with patch('urllib.request.OpenerDirector.open', return_value=Response({'code': 212})), contextlib.redirect_stderr(io.StringIO()):
  147. report, _ = probe.run(self.root, ('records',), env_file_only=True)
  148. self.assertEqual(report['checks']['records']['code'], 'dms_token_invalid')
  149. def test_login_values_and_returned_token_never_printed(self):
  150. (self.root / '.env').write_text('DMS_BASE_URL=http://localhost:1234/dms\nDMS_LOGIN_URL=http://localhost:8888/oauth/api/user/login\nDMS_USERNAME=private-user\nDMS_PASSWORD=private-password\n')
  151. def fake(request, timeout):
  152. if request.full_url.endswith('/user/login'):
  153. self.assertEqual(request.full_url, 'http://localhost:8888/oauth/api/user/login')
  154. return Response({'code': 200, 'message': 'fresh-private-token'})
  155. return self.fake(request, timeout)
  156. logs = io.StringIO()
  157. with patch('urllib.request.OpenerDirector.open', side_effect=fake), contextlib.redirect_stderr(logs):
  158. report, secrets = probe.run(self.root, ('records',), env_file_only=True)
  159. self.assertTrue(report['ok'])
  160. self.assertEqual(report['api_endpoint'], 'http://localhost:1234/dms')
  161. text = logs.getvalue() + probe.redact_report(report, secrets)
  162. for secret in ('private-user', 'private-password', 'fresh-private-token'):
  163. self.assertNotIn(secret, text)
  164. self.assertIn('"serviceId": "0"', text)
  165. class Response:
  166. status = 200
  167. length = None
  168. headers = {'Content-Type': 'application/json'}
  169. def __init__(self, payload):
  170. self.payload = payload
  171. def __enter__(self):
  172. return self
  173. def __exit__(self, *_):
  174. pass
  175. def read(self, limit):
  176. return json.dumps(self.payload).encode()[:limit]