test_config_compat.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import os
  2. import tempfile
  3. import unittest
  4. from config import GatewayConfig
  5. class GatewayConfigCompatTest(unittest.TestCase):
  6. def test_gateway_config_accepts_documented_fms_env_names_when_dotenv_missing(self):
  7. env = {
  8. 'FMS_API_BASE': 'http://gateway.example.test',
  9. 'FMS_CLIENT_TYPE': 'workbuddy',
  10. 'FMS_TIMEOUT_MS': '15000',
  11. 'FMS_LOG_LEVEL': 'debug',
  12. }
  13. config = GatewayConfig.from_env(env, dotenv_path=os.path.join(tempfile.gettempdir(), 'missing-fms-mcp.env'))
  14. self.assertEqual('http://gateway.example.test', config.auth_base_url)
  15. self.assertEqual('http://gateway.example.test', config.tools_base_url)
  16. self.assertEqual('workbuddy', config.client_type)
  17. self.assertEqual(15, config.timeout_seconds)
  18. self.assertEqual('debug', config.log_level)
  19. def test_gateway_config_reads_dotenv_file_when_env_missing(self):
  20. with tempfile.TemporaryDirectory() as tmp_dir:
  21. dotenv_path = os.path.join(tmp_dir, '.env')
  22. with open(dotenv_path, 'w', encoding='utf-8') as file:
  23. file.write('FMS_API_BASE=http://dotenv.example.test\n')
  24. file.write('FMS_CLIENT_TYPE=workbuddy\n')
  25. file.write('FMS_TIMEOUT_SECONDS=12\n')
  26. file.write('FMS_LOG_LEVEL=warning\n')
  27. file.write('FMS_TOKEN_STORE_PATH=.runtime-token.json\n')
  28. config = GatewayConfig.from_env(env={}, dotenv_path=dotenv_path)
  29. self.assertEqual('http://dotenv.example.test', config.auth_base_url)
  30. self.assertEqual('http://dotenv.example.test', config.tools_base_url)
  31. self.assertEqual('workbuddy', config.client_type)
  32. self.assertEqual(12, config.timeout_seconds)
  33. self.assertEqual('warning', config.log_level)
  34. self.assertEqual('.runtime-token.json', config.token_store_path)
  35. def test_gateway_config_prefers_dotenv_over_environment(self):
  36. with tempfile.TemporaryDirectory() as tmp_dir:
  37. dotenv_path = os.path.join(tmp_dir, '.env')
  38. with open(dotenv_path, 'w', encoding='utf-8') as file:
  39. file.write('FMS_AUTH_BASE=http://dotenv-auth.example.test\n')
  40. file.write('FMS_TOOLS_BASE=http://dotenv-tools.example.test\n')
  41. file.write('FMS_CLIENT_TYPE=workbuddy-dotenv\n')
  42. file.write('FMS_TIMEOUT_SECONDS=12\n')
  43. file.write('FMS_LOG_LEVEL=warning\n')
  44. file.write('FMS_TOKEN_STORE_PATH=.dotenv-token.json\n')
  45. config = GatewayConfig.from_env(
  46. env={
  47. 'FMS_AUTH_BASE': 'http://env-auth.example.test',
  48. 'FMS_TOOLS_BASE': 'http://env-tools.example.test',
  49. 'FMS_CLIENT_TYPE': 'workbuddy-env',
  50. 'FMS_TIMEOUT_SECONDS': '30',
  51. 'FMS_LOG_LEVEL': 'debug',
  52. 'FMS_TOKEN_STORE_PATH': '.env-token.json',
  53. },
  54. dotenv_path=dotenv_path,
  55. )
  56. self.assertEqual('http://dotenv-auth.example.test', config.auth_base_url)
  57. self.assertEqual('http://dotenv-tools.example.test', config.tools_base_url)
  58. self.assertEqual('workbuddy-dotenv', config.client_type)
  59. self.assertEqual(12, config.timeout_seconds)
  60. self.assertEqual('warning', config.log_level)
  61. self.assertEqual('.dotenv-token.json', config.token_store_path)
  62. def test_public_gateway_config_reads_session_ttl_and_mode(self):
  63. config = GatewayConfig.from_env(env={
  64. 'FMS_API_BASE': 'https://base.example.com',
  65. 'FMS_GATEWAY_MODE': 'public',
  66. 'FMS_GATEWAY_SESSION_TTL_SECONDS': '600',
  67. 'FMS_REDIS_PREFIX': 'fms:mcp:gateway:',
  68. }, dotenv_path='missing.env')
  69. self.assertEqual('public', config.gateway_mode)
  70. self.assertEqual(600, config.gateway_session_ttl_seconds)
  71. self.assertEqual('fms:mcp:gateway:', config.redis_prefix)
  72. def test_public_gateway_session_ttl_defaults_to_zero(self):
  73. config = GatewayConfig.from_env(env={
  74. 'FMS_API_BASE': 'https://base.example.com',
  75. 'FMS_GATEWAY_MODE': 'public',
  76. }, dotenv_path='missing.env')
  77. self.assertEqual(0, config.gateway_session_ttl_seconds)
  78. def test_public_gateway_session_ttl_zero_is_preserved(self):
  79. config = GatewayConfig.from_env(env={
  80. 'FMS_API_BASE': 'https://base.example.com',
  81. 'FMS_GATEWAY_MODE': 'public',
  82. 'FMS_GATEWAY_SESSION_TTL_SECONDS': '0',
  83. }, dotenv_path='missing.env')
  84. self.assertEqual(0, config.gateway_session_ttl_seconds)
  85. def test_public_gateway_mode_defaults_to_gateway_redis_prefix(self):
  86. config = GatewayConfig.from_env(env={
  87. 'FMS_API_BASE': 'https://base.example.com',
  88. 'FMS_GATEWAY_MODE': 'public',
  89. }, dotenv_path='missing.env')
  90. self.assertEqual('public', config.gateway_mode)
  91. self.assertEqual('fms:mcp:gateway:', config.redis_prefix)
  92. def test_diagnostic_reporter_config_reads_all_operational_limits(self):
  93. config = GatewayConfig.from_env(env={
  94. 'FMS_API_BASE': 'https://base.example.com',
  95. 'MCP_DIAGNOSIS_ENABLED': 'true',
  96. 'MCP_DIAGNOSIS_URL': 'https://support.internal/internal/mcp-diagnostics/events',
  97. 'MCP_DIAGNOSIS_KEY_ID': 'gateway-current',
  98. 'MCP_DIAGNOSIS_SECRET': 's' * 32,
  99. 'MCP_DIAGNOSIS_QUEUE_SIZE': '500',
  100. 'MCP_DIAGNOSIS_BATCH_SIZE': '50',
  101. 'MCP_DIAGNOSIS_TIMEOUT_SECONDS': '0.4',
  102. 'MCP_DIAGNOSIS_INITIAL_BACKOFF_SECONDS': '0.2',
  103. 'MCP_DIAGNOSIS_MAX_BACKOFF_SECONDS': '3.0',
  104. 'MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP': 'true',
  105. }, dotenv_path='missing.env')
  106. self.assertTrue(config.diagnosis_enabled)
  107. self.assertEqual(
  108. 'https://support.internal/internal/mcp-diagnostics/events',
  109. config.diagnosis_url,
  110. )
  111. self.assertEqual('gateway-current', config.diagnosis_key_id)
  112. self.assertEqual('s' * 32, config.diagnosis_secret)
  113. self.assertEqual(500, config.diagnosis_queue_size)
  114. self.assertEqual(50, config.diagnosis_batch_size)
  115. self.assertEqual(0.4, config.diagnosis_timeout_seconds)
  116. self.assertEqual(0.2, config.diagnosis_initial_backoff_seconds)
  117. self.assertEqual(3.0, config.diagnosis_max_backoff_seconds)
  118. self.assertTrue(config.diagnosis_allow_insecure_http)
  119. default_config = GatewayConfig.from_env(env={
  120. 'FMS_API_BASE': 'https://base.example.com',
  121. }, dotenv_path='missing.env')
  122. self.assertFalse(default_config.diagnosis_allow_insecure_http)
  123. def test_timeout_ms_conversion_in_preferred_env(self):
  124. """Test FMS_TIMEOUT_MS conversion in preferred env"""
  125. config = GatewayConfig.from_env(env={
  126. 'FMS_API_BASE': 'https://base.example.com',
  127. 'FMS_TIMEOUT_MS': '5000', # 5000ms = 5 seconds
  128. }, dotenv_path='missing.env')
  129. self.assertEqual(5, config.timeout_seconds)
  130. def test_timeout_ms_minimum_value(self):
  131. """Test that FMS_TIMEOUT_MS converts to at least 1 second"""
  132. config = GatewayConfig.from_env(env={
  133. 'FMS_API_BASE': 'https://base.example.com',
  134. 'FMS_TIMEOUT_MS': '500', # 500ms should become 1 second
  135. }, dotenv_path='missing.env')
  136. self.assertEqual(1, config.timeout_seconds)
  137. def test_invalid_diagnostic_float_uses_default(self):
  138. config = GatewayConfig.from_env(env={
  139. 'FMS_API_BASE': 'https://base.example.com',
  140. 'MCP_DIAGNOSIS_TIMEOUT_SECONDS': 'not-a-number',
  141. }, dotenv_path='missing.env')
  142. self.assertEqual(0.5, config.diagnosis_timeout_seconds)
  143. def test_timeout_resolution_covers_supported_env_keys(self):
  144. cases = (
  145. ({'MCP_TIMEOUT_SECONDS': '11'}, {}, 11),
  146. ({'FMS_TIMEOUT_MS': '2500'}, {}, 2),
  147. ({}, {'FMS_TIMEOUT_SECONDS': '12'}, 12),
  148. )
  149. for preferred, fallback, expected in cases:
  150. with self.subTest(preferred=preferred, fallback=fallback):
  151. self.assertEqual(
  152. expected,
  153. GatewayConfig._resolve_timeout_seconds(
  154. preferred,
  155. fallback,
  156. ),
  157. )
  158. def test_load_dotenv_skips_invalid_lines_and_unquotes_values(self):
  159. with tempfile.TemporaryDirectory() as tmp_dir:
  160. path = os.path.join(tmp_dir, '.env')
  161. with open(path, 'w', encoding='utf-8') as file:
  162. file.write('# comment\n')
  163. file.write('invalid-line\n')
  164. file.write('=ignored\n')
  165. file.write('DOUBLE="double value"\n')
  166. file.write("SINGLE='single value'\n")
  167. values = GatewayConfig._load_dotenv(path)
  168. self.assertEqual({
  169. 'DOUBLE': 'double value',
  170. 'SINGLE': 'single value',
  171. }, values)
  172. class RateLimitConfigTest(unittest.TestCase):
  173. """Tests for _parse_int / _parse_bool helpers and rate limit config parsing."""
  174. def _config_from_env(self, env):
  175. return GatewayConfig.from_env(
  176. env=dict({'FMS_API_BASE': 'http://x.test'}, **env),
  177. dotenv_path='missing.env',
  178. )
  179. # --- _parse_int via OS env var ---
  180. def test_rate_limit_max_requests_reads_from_env(self):
  181. config = self._config_from_env({'FMS_RATE_LIMIT_MAX_REQUESTS': '120'})
  182. self.assertEqual(120, config.rate_limit_max_requests)
  183. def test_rate_limit_window_seconds_reads_from_env(self):
  184. config = self._config_from_env({'FMS_RATE_LIMIT_WINDOW_SECONDS': '30'})
  185. self.assertEqual(30, config.rate_limit_window_seconds)
  186. def test_max_in_flight_per_tool_reads_from_env(self):
  187. config = self._config_from_env({'FMS_MAX_IN_FLIGHT_PER_TOOL': '3'})
  188. self.assertEqual(3, config.max_in_flight_per_tool)
  189. def test_rate_limit_max_requests_os_env_with_inline_comment_does_not_crash(self):
  190. # OS env vars are not processed by _load_dotenv, so inline comments must be
  191. # stripped by _parse_int before int() conversion
  192. config = self._config_from_env({'FMS_RATE_LIMIT_MAX_REQUESTS': '60 # max per window'})
  193. self.assertEqual(60, config.rate_limit_max_requests)
  194. def test_rate_limit_window_seconds_invalid_value_falls_back_to_default(self):
  195. config = self._config_from_env({'FMS_RATE_LIMIT_WINDOW_SECONDS': 'not_a_number'})
  196. self.assertEqual(60, config.rate_limit_window_seconds)
  197. def test_rate_limit_max_requests_empty_falls_back_to_default(self):
  198. config = self._config_from_env({})
  199. self.assertEqual(60, config.rate_limit_max_requests)
  200. # --- _parse_bool via OS env var ---
  201. def test_rate_limit_enabled_default_is_true(self):
  202. config = self._config_from_env({})
  203. self.assertTrue(config.rate_limit_enabled)
  204. def test_rate_limit_enabled_zero_disables(self):
  205. config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': '0'})
  206. self.assertFalse(config.rate_limit_enabled)
  207. def test_rate_limit_enabled_false_string_disables(self):
  208. config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': 'false'})
  209. self.assertFalse(config.rate_limit_enabled)
  210. def test_rate_limit_enabled_off_string_disables(self):
  211. config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': 'off'})
  212. self.assertFalse(config.rate_limit_enabled)
  213. def test_rate_limit_enabled_empty_string_keeps_default_enabled(self):
  214. # empty string → _pick returns '' → _parse_bool returns default=True
  215. config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': ''})
  216. self.assertTrue(config.rate_limit_enabled)
  217. def test_rate_limit_enabled_one_enables(self):
  218. config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': '1'})
  219. self.assertTrue(config.rate_limit_enabled)
  220. # --- dotenv inline comment stripping ---
  221. def test_dotenv_inline_comment_stripped_from_int_value(self):
  222. with tempfile.TemporaryDirectory() as tmp:
  223. path = os.path.join(tmp, '.env')
  224. with open(path, 'w') as f:
  225. f.write('FMS_API_BASE=http://x.test\n')
  226. f.write('FMS_RATE_LIMIT_MAX_REQUESTS=45 # requests per window\n')
  227. config = GatewayConfig.from_env(env={}, dotenv_path=path)
  228. self.assertEqual(45, config.rate_limit_max_requests)
  229. def test_dotenv_inline_comment_stripped_from_bool_value(self):
  230. with tempfile.TemporaryDirectory() as tmp:
  231. path = os.path.join(tmp, '.env')
  232. with open(path, 'w') as f:
  233. f.write('FMS_API_BASE=http://x.test\n')
  234. f.write('FMS_RATE_LIMIT_ENABLED=0 # disabled for testing\n')
  235. config = GatewayConfig.from_env(env={}, dotenv_path=path)
  236. self.assertFalse(config.rate_limit_enabled)
  237. if __name__ == '__main__':
  238. unittest.main()