py-app-runner 0.5.49.dev0__tar.gz → 0.5.50.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. py_app_runner-0.5.50.dev0/.version +1 -0
  2. {py_app_runner-0.5.49.dev0/src/py_app_runner.egg-info → py_app_runner-0.5.50.dev0}/PKG-INFO +1 -1
  3. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/request_handler/handlers.py +13 -26
  4. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/utils.py +27 -10
  5. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0/src/py_app_runner.egg-info}/PKG-INFO +1 -1
  6. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner.egg-info/SOURCES.txt +1 -0
  7. py_app_runner-0.5.50.dev0/tests/test_api_handler.py +68 -0
  8. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_error_envelope.py +11 -32
  9. py_app_runner-0.5.49.dev0/.version +0 -1
  10. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/LICENSE +0 -0
  11. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/MANIFEST.in +0 -0
  12. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/README.md +0 -0
  13. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/pyproject.toml +0 -0
  14. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/setup.cfg +0 -0
  15. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/__init__.py +0 -0
  16. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/__init__.py +0 -0
  17. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/_service.py +0 -0
  18. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/_service_args.py +0 -0
  19. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/audit.py +0 -0
  20. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/commands.py +0 -0
  21. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/diff.py +0 -0
  22. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/errors.py +0 -0
  23. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/event.py +0 -0
  24. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/audit/store.py +0 -0
  25. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/__init__.py +0 -0
  26. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/_service.py +0 -0
  27. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/_service_args.py +0 -0
  28. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/api.py +0 -0
  29. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/encoders/__init__.py +0 -0
  30. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/encoders/base.py +0 -0
  31. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/encoders/json_encoder.py +0 -0
  32. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/encoders/msgpack_encoder.py +0 -0
  33. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/web_app.py +0 -0
  34. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/bridge/websocket.py +0 -0
  35. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/colors.py +0 -0
  36. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/config.py +0 -0
  37. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/__init__.py +0 -0
  38. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/_service.py +0 -0
  39. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/_service_args.py +0 -0
  40. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/commands.py +0 -0
  41. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/envelope.py +0 -0
  42. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/errors.py +0 -0
  43. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/fields.py +0 -0
  44. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/crypto/passwords.py +0 -0
  45. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/db_pools.py +0 -0
  46. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/http_exception.py +0 -0
  47. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/logger_handlers.py +0 -0
  48. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/__init__.py +0 -0
  49. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/_service.py +0 -0
  50. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/_service_args.py +0 -0
  51. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/commands.py +0 -0
  52. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/discovery.py +0 -0
  53. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/states.py +0 -0
  54. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/migrations/tracker.py +0 -0
  55. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/py.typed +0 -0
  56. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/pybridge.py +0 -0
  57. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/__init__.py +0 -0
  58. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/_service.py +0 -0
  59. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/_service_args.py +0 -0
  60. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/commands.py +0 -0
  61. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/driver_pg.py +0 -0
  62. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/driver_redis.py +0 -0
  63. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/handler.py +0 -0
  64. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/interface.py +0 -0
  65. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/job.py +0 -0
  66. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/queue/worker.py +0 -0
  67. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/registry.py +0 -0
  68. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/request_handler/__init__.py +0 -0
  69. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/request_handler/auth_service.py +0 -0
  70. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/request_handler/decorators.py +0 -0
  71. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/request_handler/pagination.py +0 -0
  72. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/return_model.py +0 -0
  73. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/runner.py +0 -0
  74. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/throttle/__init__.py +0 -0
  75. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/throttle/throttle.py +0 -0
  76. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/tick_service.py +0 -0
  77. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/timer.py +0 -0
  78. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/wbcm/__init__.py +0 -0
  79. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/wbcm/device_connections.py +0 -0
  80. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/wbcm/factory.py +0 -0
  81. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/wbcm/wb_connection_manager.py +0 -0
  82. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner/wbcm/ws_interface.py +0 -0
  83. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner.egg-info/dependency_links.txt +0 -0
  84. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner.egg-info/requires.txt +0 -0
  85. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/src/py_app_runner.egg-info/top_level.txt +0 -0
  86. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_api_keys.py +0 -0
  87. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_audit_diff.py +0 -0
  88. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_audit_store.py +0 -0
  89. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_config.py +0 -0
  90. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_crypto_envelope.py +0 -0
  91. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_crypto_fields.py +0 -0
  92. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_crypto_passwords.py +0 -0
  93. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_decorators.py +0 -0
  94. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_example.py +0 -0
  95. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_logger_handlers.py +0 -0
  96. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_migrations_commands.py +0 -0
  97. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_migrations_discovery.py +0 -0
  98. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_migrations_pg_harness.py +0 -0
  99. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_migrations_service.py +0 -0
  100. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_migrations_states.py +0 -0
  101. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_migrations_tracker.py +0 -0
  102. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_pybridge.py +0 -0
  103. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_queue_contract.py +0 -0
  104. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_queue_driver.py +0 -0
  105. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_queue_driver_redis.py +0 -0
  106. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_queue_worker.py +0 -0
  107. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_throttle.py +0 -0
  108. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_utils.py +0 -0
  109. {py_app_runner-0.5.49.dev0 → py_app_runner-0.5.50.dev0}/tests/test_wbcm.py +0 -0
@@ -0,0 +1 @@
1
+ 0.5.50.dev0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py_app_runner
3
- Version: 0.5.49.dev0
3
+ Version: 0.5.50.dev0
4
4
  Summary: Async service framework: Tornado HTTP/WS bridge, service loader, Redis WS connection manager
5
5
  Author-email: Gints Murans <gm@gm.lv>
6
6
  License-Expression: MIT
@@ -153,25 +153,15 @@ class RequestHandlerBase(web.RequestHandler):
153
153
  )
154
154
 
155
155
  def get_request_data(self, force_action: str | None = None) -> dict[str, Any]:
156
- query_params = self.request.query_arguments
157
- decoded_params: dict[str, Any] = {}
156
+ """The JSON body is the request. The URL may only name the action - anything else
157
+ in the query string would end up in access logs and caches as service input."""
158
+ request_data: dict[str, Any] = dict(self.json_args or {})
158
159
 
159
- for k, v in query_params.items():
160
- # Check if there's only one item in the list
161
- if len(v) == 1:
162
- decoded_params[k] = v[0].decode("utf-8")
163
- else:
164
- # Decode each item in the list
165
- decoded_params[k] = [item.decode("utf-8") for item in v]
166
-
167
- request_body = {}
168
- if hasattr(self, "json_args") and self.json_args:
169
- request_body = self.json_args
160
+ action = force_action or request_data.get("action") or self.get_query_argument("action", None)
161
+ if action:
162
+ request_data["action"] = action
170
163
 
171
- if force_action:
172
- request_body["action"] = force_action
173
-
174
- return {**decoded_params, **request_body}
164
+ return request_data
175
165
 
176
166
  def error(
177
167
  self,
@@ -232,7 +222,7 @@ class RequestHandlerBase(web.RequestHandler):
232
222
  method,
233
223
  path,
234
224
  body_len,
235
- args if isinstance(args, dict) else "<n/a>", # type: ignore
225
+ redact_headers(args) if isinstance(args, dict) else "<n/a>",
236
226
  )
237
227
 
238
228
  # details go at DEBUG unless we're already warning/error, then use same level
@@ -270,12 +260,12 @@ class RequestHandlerBase(web.RequestHandler):
270
260
  await res
271
261
 
272
262
  # Debug
273
- headers = dict(self.request.headers)
274
- headers.pop("Authorization", None)
275
-
276
263
  self.logger.debug(f"Request: {self.request}")
277
- self.logger.debug(f"Headers: {headers}")
278
- self.logger.debug(f"Body: {self.request.body!r}")
264
+ self.logger.debug("Headers: %s", redact_headers(dict(self.request.headers)))
265
+ self.logger.debug(
266
+ "Body: %s",
267
+ format_body_for_log(self.request.body, max_chars=4000, shrink_strings_to=256, cap_list_items=50),
268
+ )
279
269
  self.logger.debug(f"Arguments: {self.request.arguments.keys()}")
280
270
 
281
271
  self._ensure_content_type()
@@ -458,9 +448,6 @@ class RequestHandlerApiKeys(RequestHandlerBase):
458
448
 
459
449
  async def has_valid_api_key(self, custom_api_key: str | None = None) -> str | Literal[True]:
460
450
  api_key: str | None = custom_api_key or self.request.headers.get("X-API-Key", None) # type: ignore
461
- if api_key is None:
462
- api_key = self.get_argument("api_key", None)
463
-
464
451
  if api_key is None:
465
452
  return "API key is missing"
466
453
 
@@ -278,15 +278,29 @@ async def run_blocking(fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) ->
278
278
  return await loop.run_in_executor(None, partial(fn, *args, **kwargs))
279
279
 
280
280
 
281
+ SENSITIVE_KEY_MARKERS = (
282
+ "authorization",
283
+ "cookie",
284
+ "password",
285
+ "passwd",
286
+ "secret",
287
+ "token",
288
+ "api-key",
289
+ "api_key",
290
+ "apikey",
291
+ "credential",
292
+ )
293
+
294
+
295
+ def is_sensitive_key(name: Any) -> bool:
296
+ """Substring match on header names and JSON keys, so `X-Auth-Token`, `auth_token`,
297
+ `refresh_token` and `client_secret` are all caught without listing each one."""
298
+ lowered = str(name).lower()
299
+ return any(marker in lowered for marker in SENSITIVE_KEY_MARKERS)
300
+
301
+
281
302
  def redact_headers(headers: dict[str, Any]) -> dict[str, Any]:
282
- redacted: dict[str, Any] = {}
283
- for k, v in (headers or {}).items():
284
- kl = str(k).lower()
285
- if kl in ("authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key"):
286
- redacted[k] = "<redacted>"
287
- else:
288
- redacted[k] = v
289
- return redacted
303
+ return {k: ("<redacted>" if is_sensitive_key(k) else v) for k, v in (headers or {}).items()}
290
304
 
291
305
 
292
306
  def sha1_prefix(b: bytes, n: int = 12) -> str:
@@ -300,13 +314,16 @@ def sha256_hash(s: str) -> str:
300
314
  def shrink_json(obj: Any, *, max_string: int, max_items: int) -> Any:
301
315
  """
302
316
  Traverse JSON-like structures and:
317
+ - redact values under credential-looking keys (see is_sensitive_key),
303
318
  - truncate any string longer than max_string (base64-aware summary),
304
319
  - cap lists to max_items with a "… N more items" marker,
305
320
  - leave numbers/bools/None untouched.
306
- No key-based assumptions.
307
321
  """
308
322
  if isinstance(obj, dict):
309
- return {k: shrink_json(v, max_string=max_string, max_items=max_items) for k, v in obj.items()}
323
+ return {
324
+ k: ("<redacted>" if is_sensitive_key(k) else shrink_json(v, max_string=max_string, max_items=max_items))
325
+ for k, v in obj.items()
326
+ }
310
327
  if isinstance(obj, list):
311
328
  trimmed = [shrink_json(x, max_string=max_string, max_items=max_items) for x in obj[:max_items]]
312
329
  if len(obj) > max_items:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py_app_runner
3
- Version: 0.5.49.dev0
3
+ Version: 0.5.50.dev0
4
4
  Summary: Async service framework: Tornado HTTP/WS bridge, service loader, Redis WS connection manager
5
5
  Author-email: Gints Murans <gm@gm.lv>
6
6
  License-Expression: MIT
@@ -78,6 +78,7 @@ src/py_app_runner/wbcm/device_connections.py
78
78
  src/py_app_runner/wbcm/factory.py
79
79
  src/py_app_runner/wbcm/wb_connection_manager.py
80
80
  src/py_app_runner/wbcm/ws_interface.py
81
+ tests/test_api_handler.py
81
82
  tests/test_api_keys.py
82
83
  tests/test_audit_diff.py
83
84
  tests/test_audit_store.py
@@ -0,0 +1,68 @@
1
+ from tornado.testing import AsyncHTTPTestCase
2
+ from tornado.web import Application
3
+
4
+ from py_app_runner.http_exception import HTTPException
5
+ from py_app_runner.registry import AppRegistry
6
+ from py_app_runner.utils import json_encode
7
+
8
+ from .api_harness import FakeService, make_api_app
9
+
10
+ API_KEY = "top-secret-key"
11
+
12
+
13
+ class TestApiHandlerInput(AsyncHTTPTestCase):
14
+ def get_app(self) -> Application:
15
+ AppRegistry.configure(
16
+ config={"environment": "test", "debug": False, "api": {"key": API_KEY}, "jwt": {"secret": "jwt-secret"}},
17
+ users_model=object,
18
+ api_keys_model=object,
19
+ )
20
+ self.echo = FakeService(result={"status": "ok"})
21
+ self.guarded = FakeService(result={"status": "ok"}, requires_api_key=None)
22
+ self.rejecting = FakeService(error=HTTPException("bad credentials", code=-401, http_status=401))
23
+ return make_api_app({"echo": self.echo, "guarded": self.guarded, "rejecting": self.rejecting})
24
+
25
+ def test_credentials_never_reach_the_logs(self):
26
+ # Every HTTPException logs the headers and the full body at WARNING, so a failed
27
+ # login would otherwise write the password and the caller's tokens to the log.
28
+ body = {
29
+ "action": "login",
30
+ "auth_token": "token-in-body",
31
+ "data": {"email": "a@b.c", "password": "hunter2"},
32
+ }
33
+ headers = {
34
+ "Content-Type": "application/json",
35
+ "Authorization": "Bearer token-in-authorization",
36
+ "X-Auth-Token": "token-in-custom-header",
37
+ "X-Api-Secret": "secret-in-custom-header",
38
+ "X-API-Key": API_KEY,
39
+ }
40
+ with self.assertLogs("py_app_runner", level="DEBUG") as captured:
41
+ response = self.fetch("/api/rejecting", method="POST", body=json_encode(body), headers=headers)
42
+
43
+ assert response.code == 401
44
+ logged = "\n".join(captured.output)
45
+ for secret in (
46
+ "hunter2",
47
+ "token-in-body",
48
+ "token-in-authorization",
49
+ "token-in-custom-header",
50
+ "secret-in-custom-header",
51
+ API_KEY,
52
+ ):
53
+ assert secret not in logged, secret
54
+ # Redaction is targeted, not a blanket suppression of the body.
55
+ assert "login" in logged
56
+
57
+ def test_api_key_is_read_from_the_header_only(self):
58
+ # A key in the query string lands in proxy access logs and Referer headers.
59
+ assert self.fetch(f"/api/guarded?action=go&api_key={API_KEY}").code == 401
60
+ assert self.fetch("/api/guarded?action=go", headers={"X-API-Key": API_KEY}).code == 200
61
+
62
+ def test_query_parameters_are_not_service_input(self):
63
+ # Only the JSON body feeds the service. A `data` from the URL is a bare string
64
+ # that no handler expecting a dict can use, and URLs get logged and cached.
65
+ response = self.fetch("/api/echo?action=go&data=from-url&extra=1")
66
+
67
+ assert response.code == 200
68
+ assert self.echo.calls == [("go", {})]
@@ -7,6 +7,9 @@ from py_app_runner.http_exception import HTTPException
7
7
  from py_app_runner.registry import AppRegistry
8
8
  from py_app_runner.utils import json_decode
9
9
 
10
+ from .api_harness import FakeService
11
+ from .api_harness import make_api_app as _make_api_app
12
+
10
13
 
11
14
  def make_app() -> Application:
12
15
  from py_app_runner.request_handler.handlers import WebHandlerBase
@@ -32,39 +35,15 @@ def make_app() -> Application:
32
35
  )
33
36
 
34
37
 
35
- class FakeService:
36
- """Stands in for a services.<name>._service_pybridge module."""
37
-
38
- requires_api_key = False
39
-
40
- def __init__(self, result: Any = None, error: Exception | None = None):
41
- self.result = result
42
- self.error = error
43
-
44
- async def bridge_request(self, action: str, request_data: dict[str, Any], bridge_handler: Any) -> Any:
45
- if self.error is not None:
46
- raise self.error
47
- return self.result
48
-
49
-
50
38
  def make_api_app() -> Application:
51
- from py_app_runner.bridge.api import ApiHandler
52
- from py_app_runner.pybridge import PyBridge
53
- from py_app_runner.request_handler.handlers import WebApplicationBase
54
-
55
- class ApiApplication(WebApplicationBase):
56
- pass
57
-
58
- app = ApiApplication([(r"/api/([^/]+)", ApiHandler)])
59
-
60
- pybridge = PyBridge()
61
- pybridge.cache_service("boom", FakeService(error=RuntimeError("service exploded")))
62
- pybridge.cache_service("known", FakeService(error=HTTPException("nope", code=-7, http_status=403)))
63
- pybridge.cache_service("fine", FakeService(result={"status": "ok"}))
64
- pybridge.cache_service("silent", FakeService(result=None))
65
- app.set_pybridge(pybridge)
66
-
67
- return app
39
+ return _make_api_app(
40
+ {
41
+ "boom": FakeService(error=RuntimeError("service exploded")),
42
+ "known": FakeService(error=HTTPException("nope", code=-7, http_status=403)),
43
+ "fine": FakeService(result={"status": "ok"}),
44
+ "silent": FakeService(result=None),
45
+ }
46
+ )
68
47
 
69
48
 
70
49
  class TestApiHandlerStatus(AsyncHTTPTestCase):
@@ -1 +0,0 @@
1
- 0.5.49.dev0