simple-module-hosting 0.0.24__tar.gz → 0.0.25__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 (62) hide show
  1. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/PKG-INFO +6 -5
  2. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/README.md +3 -2
  3. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/pyproject.toml +3 -3
  4. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_error_handlers.py +7 -0
  5. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_phase_helpers.py +22 -24
  6. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/app_builder.py +3 -4
  7. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/bootstrap_settings.py +25 -1
  8. simple_module_hosting-0.0.25/simple_module_hosting/static_files.py +102 -0
  9. simple_module_hosting-0.0.25/tests/test_error_page_shared_props.py +67 -0
  10. simple_module_hosting-0.0.25/tests/test_menu_urls_are_canonical.py +65 -0
  11. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_middleware_order.py +7 -2
  12. simple_module_hosting-0.0.25/tests/test_precompressed_static.py +142 -0
  13. simple_module_hosting-0.0.25/tests/test_proxy_headers.py +119 -0
  14. simple_module_hosting-0.0.25/tests/test_response_compression.py +88 -0
  15. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/.gitignore +0 -0
  16. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/LICENSE +0 -0
  17. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/__init__.py +0 -0
  18. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/__main__.py +0 -0
  19. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_host_services.py +0 -0
  20. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_hydrate_step.py +0 -0
  21. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_inertia_setup.py +0 -0
  22. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_inertia_shared.py +0 -0
  23. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/_observability.py +0 -0
  24. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/health.py +0 -0
  25. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/host_cli.py +0 -0
  26. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/host_settings.py +0 -0
  27. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_deps.py +0 -0
  28. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_manifest.py +0 -0
  29. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_middleware.py +0 -0
  30. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/inertia_deps.py +0 -0
  31. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/inertia_utils.py +0 -0
  32. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/logging.py +0 -0
  33. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/manifest.py +0 -0
  34. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/middleware.py +0 -0
  35. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/migrations.py +0 -0
  36. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/permissions.py +0 -0
  37. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/py.typed +0 -0
  38. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/redirects.py +0 -0
  39. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/settings.py +0 -0
  40. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/simple_module_hosting/shared_props.py +0 -0
  41. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_app.py +0 -0
  42. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_branding_head.py +0 -0
  43. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_check_migrations.py +0 -0
  44. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_health.py +0 -0
  45. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_host_cli.py +0 -0
  46. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_hosting_permissions.py +0 -0
  47. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_i18n_manifest.py +0 -0
  48. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_inertia_i18n_shared_props.py +0 -0
  49. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_inertia_manifest.py +0 -0
  50. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_inertia_shared_providers.py +0 -0
  51. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_lifespan_order.py +0 -0
  52. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_locale_middleware.py +0 -0
  53. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_logging.py +0 -0
  54. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_manifest.py +0 -0
  55. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_redirects.py +0 -0
  56. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_session_cookie_security.py +0 -0
  57. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_settings_i18n.py +0 -0
  58. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_settings_secrets.py +0 -0
  59. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_static_caching.py +0 -0
  60. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_strict_discovery_wiring.py +0 -0
  61. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_tenant_middleware.py +0 -0
  62. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.25}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_hosting
3
- Version: 0.0.24
3
+ Version: 0.0.25
4
4
  Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -26,8 +26,8 @@ Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.24
30
- Requires-Dist: simple-module-db==0.0.24
29
+ Requires-Dist: simple-module-core==0.0.25
30
+ Requires-Dist: simple-module-db==0.0.25
31
31
  Requires-Dist: starlette>=0.44
32
32
  Requires-Dist: tomlkit>=0.13
33
33
  Requires-Dist: uvicorn[standard]>=0.34
@@ -64,11 +64,12 @@ Minimal `main.py`:
64
64
  from simple_module_hosting import create_app
65
65
  from simple_module_hosting.settings import Settings
66
66
 
67
- settings = Settings() # reads SM_* env vars
68
- app = create_app(settings) # discovers + registers every installed module
67
+ settings = Settings() # reads SM_* env vars
68
+ app = create_app(settings) # discovers + registers every installed module
69
69
 
70
70
  if __name__ == "__main__":
71
71
  import uvicorn
72
+
72
73
  uvicorn.run(app, host="0.0.0.0", port=8000)
73
74
  ```
74
75
 
@@ -29,11 +29,12 @@ Minimal `main.py`:
29
29
  from simple_module_hosting import create_app
30
30
  from simple_module_hosting.settings import Settings
31
31
 
32
- settings = Settings() # reads SM_* env vars
33
- app = create_app(settings) # discovers + registers every installed module
32
+ settings = Settings() # reads SM_* env vars
33
+ app = create_app(settings) # discovers + registers every installed module
34
34
 
35
35
  if __name__ == "__main__":
36
36
  import uvicorn
37
+
37
38
  uvicorn.run(app, host="0.0.0.0", port=8000)
38
39
  ```
39
40
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.24"
3
+ version = "0.0.25"
4
4
  description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
28
  "jinja2>=3.1",
29
- "simple_module_core==0.0.24",
30
- "simple_module_db==0.0.24",
29
+ "simple_module_core==0.0.25",
30
+ "simple_module_db==0.0.25",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "uvicorn[standard]>=0.34",
@@ -27,6 +27,13 @@ async def render_error_page(request: Request, status_code: int, message: str) ->
27
27
  config: InertiaConfig = request.app.state.sm.inertia_config
28
28
  try:
29
29
  inertia = Inertia(request, config)
30
+ # This builds its own Inertia instead of going through get_inertia, so
31
+ # the share step has to be repeated here. Without it the error page
32
+ # renders raw translation keys (host.error.not_found_title) and loses
33
+ # auth/menus, so a signed-in user's 404 has no layout.
34
+ shared = getattr(request.state, "inertia_shared", None)
35
+ if shared:
36
+ inertia.share(**shared)
30
37
  response = await inertia.render("Error", {"status": status_code, "message": message})
31
38
  response.status_code = status_code
32
39
  return response
@@ -22,9 +22,9 @@ from inertia import (
22
22
  from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
23
23
  from simple_module_core.exceptions import NotFoundError
24
24
  from starlette.exceptions import HTTPException
25
+ from starlette.middleware.gzip import GZipMiddleware
25
26
  from starlette.middleware.sessions import SessionMiddleware
26
- from starlette.responses import Response
27
- from starlette.types import Scope
27
+ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
28
28
 
29
29
  from simple_module_hosting._error_handlers import (
30
30
  http_exception_handler,
@@ -41,6 +41,7 @@ from simple_module_hosting.middleware import (
41
41
  TenantMiddleware,
42
42
  )
43
43
  from simple_module_hosting.settings import Settings
44
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
44
45
 
45
46
  if TYPE_CHECKING:
46
47
  from simple_module_core.menu import MenuRegistry
@@ -48,27 +49,11 @@ if TYPE_CHECKING:
48
49
 
49
50
  logger = logging.getLogger(__name__)
50
51
 
51
- _IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
52
+ # Below this, gzip framing costs more than it saves. Starlette's own default.
53
+ COMPRESSION_MIN_BYTES = 500
52
54
 
53
-
54
- class ImmutableStaticFiles(StaticFiles):
55
- """StaticFiles that marks Vite's content-hashed build assets immutable.
56
-
57
- Vite emits files under ``dist/assets/`` with a content hash in the filename
58
- (e.g. ``main-3YbShAJ4.js``), so the bytes for a given URL never change —
59
- browsers can cache them indefinitely and skip even the revalidation
60
- round-trip. The default StaticFiles only sets ETag/Last-Modified, forcing a
61
- conditional GET per asset on every visit. Non-hashed paths (the manifest,
62
- etc.) keep the default behaviour.
63
- """
64
-
65
- async def get_response(self, path: str, scope: Scope) -> Response:
66
- response = await super().get_response(path, scope)
67
- # StaticFiles hands us an OS-separator path (backslashes on Windows), so
68
- # normalize before matching the forward-slash asset prefix.
69
- if response.status_code == 200 and path.replace("\\", "/").startswith("dist/assets/"):
70
- response.headers["Cache-Control"] = _IMMUTABLE_CACHE_CONTROL
71
- return response
55
+ # Re-exported for back-compat: static-file serving now lives in static_files.
56
+ ImmutableStaticFiles = PrecompressedStaticFiles
72
57
 
73
58
 
74
59
  def register_exception_handlers(app: FastAPI, modules: list) -> None:
@@ -95,8 +80,8 @@ def install_middleware(
95
80
  """Install the full middleware pipeline.
96
81
 
97
82
  Order matters: last added = first executed. Execution order:
98
- CorrelationId → RequestLogging → Security → Session
99
- → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
83
+ (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
84
+ → Security → Session → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
100
85
  """
101
86
  app.add_middleware(
102
87
  InertiaLayoutDataMiddleware,
@@ -125,8 +110,21 @@ def install_middleware(
125
110
  )
126
111
  else:
127
112
  app.add_middleware(SecurityHeadersMiddleware)
113
+ # Compress response bodies. Added here so it sits inside CorrelationId and
114
+ # RequestLogging (which set headers and read request state) but outside
115
+ # everything that produces a body — including the /static mount, where it
116
+ # matters most: the built CSS is ~139 KB raw and ~21 KB gzipped, and the
117
+ # JS bundle compresses about 3x. Uncompressed assets dominated cold page
118
+ # load, several times larger than anything on the server request path.
119
+ app.add_middleware(GZipMiddleware, minimum_size=COMPRESSION_MIN_BYTES)
128
120
  app.add_middleware(RequestLoggingMiddleware)
129
121
  app.add_middleware(CorrelationIdMiddleware)
122
+ # Outermost: rewrite scheme/client from X-Forwarded-* before anything else
123
+ # reads them, so request logs see the real client IP and Inertia's absolute
124
+ # page url carries the proxy-terminated scheme (GH #223). Gated on an
125
+ # explicit trust setting — never trust forwarded headers by default.
126
+ if settings.trusted_proxy:
127
+ app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
130
128
 
131
129
 
132
130
  def attach_public_routes(app: FastAPI, settings: Settings, registry) -> None:
@@ -25,7 +25,6 @@ from simple_module_db.session import init_db
25
25
  from simple_module_hosting._host_services import _HostServices
26
26
  from simple_module_hosting._inertia_setup import setup_inertia
27
27
  from simple_module_hosting._phase_helpers import (
28
- ImmutableStaticFiles,
29
28
  attach_public_routes,
30
29
  check_settings_registration,
31
30
  install_middleware,
@@ -38,6 +37,7 @@ from simple_module_hosting.host_settings import HostSettings
38
37
  from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
39
38
  from simple_module_hosting.migrations import check_migrations
40
39
  from simple_module_hosting.settings import Settings
40
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
41
41
 
42
42
  logger = logging.getLogger(__name__)
43
43
 
@@ -277,9 +277,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
277
277
 
278
278
  static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
279
279
  if static_dir.is_dir():
280
- app.mount(
281
- _STATIC_MOUNT_PATH, ImmutableStaticFiles(directory=static_dir), name=_STATIC_DIR_NAME
282
- )
280
+ static = PrecompressedStaticFiles(directory=static_dir)
281
+ app.mount(_STATIC_MOUNT_PATH, static, name=_STATIC_DIR_NAME)
283
282
 
284
283
  mount_module_static_dirs(app, modules)
285
284
 
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Literal
11
11
 
12
- from pydantic import model_validator
12
+ from pydantic import field_validator, model_validator
13
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
14
14
  from simple_module_core.environments import NON_PROD_ENVIRONMENTS
15
15
 
@@ -32,6 +32,15 @@ class BootstrapSettings(BaseSettings):
32
32
  vite_dev_url: str = "http://localhost:5050"
33
33
  debug: bool = False
34
34
 
35
+ # Trust the X-Forwarded-* headers from a fronting reverse proxy. Unset by
36
+ # default (no proxy trusted). Set to ``*`` to trust any peer (correct when
37
+ # the container is only reachable through a single proxy), or a comma-
38
+ # separated list of proxy IPs / CIDRs. Drives uvicorn's
39
+ # ProxyHeadersMiddleware so request.url.scheme reflects X-Forwarded-Proto
40
+ # behind a TLS-terminating proxy — without it Inertia's pushState throws a
41
+ # cross-scheme SecurityError and login breaks (GH #223).
42
+ trusted_proxy: str | None = None
43
+
35
44
  log_level: str = "INFO"
36
45
  log_format: Literal["json", "text"] = "json"
37
46
 
@@ -46,6 +55,21 @@ class BootstrapSettings(BaseSettings):
46
55
  ``register_public_routes`` hook, which is method-aware.
47
56
  """
48
57
 
58
+ @field_validator("trusted_proxy", mode="after")
59
+ @classmethod
60
+ def _normalize_trusted_proxy(cls, value: str | None) -> str | None:
61
+ """Strip surrounding whitespace; treat blank as unset.
62
+
63
+ Guards against a stray space silently defeating the feature: uvicorn
64
+ decides ``*``-trust by comparing the *raw* string to ``"*"`` before it
65
+ strips, so ``"* "`` would be parsed as a literal host that matches no
66
+ client — re-introducing GH #223 with no error. Blank → ``None`` so the
67
+ middleware isn't installed at all.
68
+ """
69
+ if value is None:
70
+ return None
71
+ return value.strip() or None
72
+
49
73
  @property
50
74
  def is_development(self) -> bool:
51
75
  return self.environment == "development"
@@ -0,0 +1,102 @@
1
+ """Static-file serving: immutable caching and pre-compressed variants.
2
+
3
+ Split out of ``_phase_helpers`` — static delivery is its own responsibility,
4
+ and that module was near the repo's 300-line cap.
5
+
6
+ Two optimizations live here, both aimed at cold page load:
7
+
8
+ * **Immutable caching.** Vite emits content-hashed filenames, so the bytes for
9
+ a given URL never change and the browser can skip even the revalidation
10
+ round-trip.
11
+ * **Pre-compressed variants.** ``GZipMiddleware`` would otherwise re-compress
12
+ the same immutable bundle on every request, using a fast (and therefore
13
+ worse) compression level. Files compressed once at build time can afford the
14
+ maximum level, and brotli is ~14% smaller than gzip on this bundle.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from mimetypes import guess_type
20
+
21
+ from fastapi.staticfiles import StaticFiles
22
+ from starlette.datastructures import Headers
23
+ from starlette.exceptions import HTTPException
24
+ from starlette.responses import Response
25
+ from starlette.types import Scope
26
+
27
+ __all__ = ["IMMUTABLE_CACHE_CONTROL", "PrecompressedStaticFiles"]
28
+
29
+ IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
30
+
31
+ _HASHED_ASSET_PREFIX = "dist/assets/"
32
+ _ACCEPT_ENCODING = "accept-encoding"
33
+ _CONTENT_ENCODING = "Content-Encoding"
34
+ _CONTENT_TYPE = "content-type"
35
+ _VARY = "Vary"
36
+ _ACCEPT_ENCODING_HEADER = "Accept-Encoding"
37
+ _OK = 200
38
+
39
+ # (encoding token, filename suffix), best first. Brotli wins where offered:
40
+ # measured 248.5 KB vs 287.6 KB gzip-9 across this bundle.
41
+ _VARIANTS: tuple[tuple[str, str], ...] = (("br", ".br"), ("gzip", ".gz"))
42
+
43
+
44
+ def _accepted_encodings(scope: Scope) -> set[str]:
45
+ """Encoding tokens the client advertises.
46
+
47
+ Deliberately ignores q-values. Any client sending ``br;q=0`` to actively
48
+ refuse brotli is vanishingly rare, and getting it wrong only costs a few
49
+ KB — whereas parsing it wrong could serve a body the client cannot read.
50
+ Tokens are matched exactly, so a ``q=0`` entry still reads as "accepted";
51
+ that is the conservative direction only because every real client that
52
+ lists an encoding can decode it.
53
+ """
54
+ raw = Headers(scope=scope).get(_ACCEPT_ENCODING, "")
55
+ return {part.split(";")[0].strip().lower() for part in raw.split(",") if part.strip()}
56
+
57
+
58
+ class PrecompressedStaticFiles(StaticFiles):
59
+ """Serve ``.br``/``.gz`` siblings when present, and mark hashed assets immutable.
60
+
61
+ A request for ``app.js`` is answered with ``app.js.br`` when the client
62
+ accepts brotli and that file exists, falling back to ``.gz`` and then to the
63
+ original. The response keeps the *original* file's Content-Type — serving
64
+ ``app.js.br`` directly would have Starlette type it from the ``.br``
65
+ extension, and a browser will refuse to execute a script typed as
66
+ ``application/octet-stream``.
67
+
68
+ ``Vary: Accept-Encoding`` is always set on a negotiated response so shared
69
+ caches cannot hand a compressed body to a client that never asked for one.
70
+ """
71
+
72
+ async def get_response(self, path: str, scope: Scope) -> Response:
73
+ response = await self._get_negotiated_response(path, scope)
74
+ # StaticFiles hands us an OS-separator path (backslashes on Windows), so
75
+ # normalize before matching the forward-slash asset prefix.
76
+ if response.status_code == _OK and path.replace("\\", "/").startswith(_HASHED_ASSET_PREFIX):
77
+ response.headers["Cache-Control"] = IMMUTABLE_CACHE_CONTROL
78
+ return response
79
+
80
+ async def _get_negotiated_response(self, path: str, scope: Scope) -> Response:
81
+ accepted = _accepted_encodings(scope)
82
+ for encoding, suffix in _VARIANTS:
83
+ if encoding not in accepted:
84
+ continue
85
+ # StaticFiles signals "no such file" by *raising* HTTPException(404),
86
+ # not by returning a 404 response — so a missing variant has to be
87
+ # caught here, not detected from a status code.
88
+ try:
89
+ variant = await super().get_response(path + suffix, scope)
90
+ except HTTPException:
91
+ continue
92
+ if variant.status_code != _OK:
93
+ continue
94
+ # Type by the original filename, not the variant's extension: a
95
+ # browser refuses to execute a script typed application/octet-stream.
96
+ content_type = guess_type(path)[0]
97
+ if content_type:
98
+ variant.headers[_CONTENT_TYPE] = content_type
99
+ variant.headers[_CONTENT_ENCODING] = encoding
100
+ variant.headers[_VARY] = _ACCEPT_ENCODING_HEADER
101
+ return variant
102
+ return await super().get_response(path, scope)
@@ -0,0 +1,67 @@
1
+ """The Inertia error page must carry the same shared props as any other page.
2
+
3
+ ``render_error_page`` builds its own ``Inertia`` instance instead of going
4
+ through ``get_inertia``, so it skipped the ``inertia.share(**shared)`` step.
5
+ The consequence was visible: a 404 rendered its copy as raw translation keys
6
+ — ``host.error.not_found_title``, ``host.error.go_home`` — because the page
7
+ received no ``i18n`` block at all. It also lost ``auth`` and ``menus``, so the
8
+ error page could not render the layout a signed-in user expects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import html
14
+ import json
15
+ import re
16
+
17
+ import httpx
18
+ import pytest
19
+
20
+ _NOT_FOUND = 404
21
+ _MISSING_PATH = "/definitely/not/a/real/route"
22
+
23
+
24
+ def _inertia_page(body: str) -> dict:
25
+ """Pull the JSON blob Inertia embeds in the server-rendered document."""
26
+ match = re.search(r'data-page="([^"]+)"', body) or re.search(r"data-page='([^']+)'", body)
27
+ if match is None:
28
+ pytest.fail("no data-page attribute found — the error page did not render via Inertia")
29
+ return json.loads(html.unescape(match.group(1)))
30
+
31
+
32
+ class TestErrorPageSharedProps:
33
+ async def test_error_page_renders_via_inertia(
34
+ self, authenticated_client: httpx.AsyncClient
35
+ ) -> None:
36
+ resp = await authenticated_client.get(_MISSING_PATH)
37
+ assert resp.status_code == _NOT_FOUND
38
+ assert _inertia_page(resp.text)["component"] == "Error"
39
+
40
+ async def test_error_page_carries_i18n_messages(
41
+ self, authenticated_client: httpx.AsyncClient
42
+ ) -> None:
43
+ """Without this the page prints raw keys like host.error.not_found_title."""
44
+ resp = await authenticated_client.get(_MISSING_PATH)
45
+ props = _inertia_page(resp.text)["props"]
46
+ assert "i18n" in props, f"error page has no i18n block; props={sorted(props)}"
47
+ assert props["i18n"].get("messages"), "i18n block present but messages are empty"
48
+
49
+ async def test_error_page_carries_auth_and_menus(
50
+ self, authenticated_client: httpx.AsyncClient
51
+ ) -> None:
52
+ """The layout needs these to render for a signed-in user."""
53
+ props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
54
+ assert "auth" in props, f"error page has no auth block; props={sorted(props)}"
55
+ assert "menus" in props, f"error page has no menus block; props={sorted(props)}"
56
+
57
+ async def test_error_page_keeps_its_own_props(
58
+ self, authenticated_client: httpx.AsyncClient
59
+ ) -> None:
60
+ """Sharing must not clobber status/message."""
61
+ props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
62
+ assert props["status"] == _NOT_FOUND
63
+
64
+ async def test_anonymous_error_page_still_renders(self, client: httpx.AsyncClient) -> None:
65
+ """An unauthenticated 404 must not blow up on missing shared state."""
66
+ resp = await client.get("/health/definitely-not-real")
67
+ assert resp.status_code >= _NOT_FOUND
@@ -0,0 +1,65 @@
1
+ """Every sidebar link must hit its route directly, with no redirect.
2
+
3
+ A menu item pointing at ``/audit_log`` when the route is registered at
4
+ ``/audit_log/`` still works — Starlette 307s to the canonical path and the
5
+ client follows. But it costs a full extra round trip on *every* navigation to
6
+ that page. On localhost that's ~8 ms and invisible; on a 40 ms-latency link it
7
+ roughly doubles the navigation, and it is worse on mobile.
8
+
9
+ Five of the eight sidebar links had this mismatch when this test was written.
10
+ It is a whole class of bug that no functional test catches, because everything
11
+ still renders correctly — just slower.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import httpx
17
+ import pytest
18
+ from simple_module_hosting.middleware import InertiaLayoutDataMiddleware
19
+
20
+ _REDIRECT_CODES = {301, 302, 307, 308}
21
+ _NOT_FOUND = 404
22
+
23
+
24
+ def _menu_urls(app) -> list[str]:
25
+ """Every internal menu URL registered by the installed modules."""
26
+ for mw in app.user_middleware:
27
+ if mw.cls is InertiaLayoutDataMiddleware:
28
+ registry = mw.kwargs["menu_registry"]
29
+ break
30
+ else: # pragma: no cover - the middleware is always installed
31
+ pytest.fail("InertiaLayoutDataMiddleware not installed")
32
+
33
+ return sorted(
34
+ {
35
+ item.url
36
+ for item in registry.all_items
37
+ if item.url.startswith("/") and item.method == "get"
38
+ }
39
+ )
40
+
41
+
42
+ async def test_menu_urls_do_not_redirect(app, authenticated_client: httpx.AsyncClient) -> None:
43
+ """No sidebar link may cost a redirect hop."""
44
+ offenders: list[str] = []
45
+ for url in _menu_urls(app):
46
+ resp = await authenticated_client.get(url, headers={"X-Inertia": "true"})
47
+ if resp.status_code in _REDIRECT_CODES:
48
+ offenders.append(f"{url} -> {resp.status_code} {resp.headers.get('location', '')}")
49
+
50
+ assert not offenders, (
51
+ "These menu URLs redirect, costing an extra round trip on every "
52
+ "navigation. Point the menu item at the canonical path (usually adding "
53
+ "a trailing slash), or register the route without one:\n " + "\n ".join(offenders)
54
+ )
55
+
56
+
57
+ async def test_menu_urls_resolve(app, authenticated_client: httpx.AsyncClient) -> None:
58
+ """A menu item pointing at a route that doesn't exist is a dead link."""
59
+ dead: list[str] = []
60
+ for url in _menu_urls(app):
61
+ resp = await authenticated_client.get(url, headers={"X-Inertia": "true"})
62
+ if resp.status_code == _NOT_FOUND:
63
+ dead.append(url)
64
+
65
+ assert not dead, f"menu URLs resolve to 404: {dead}"
@@ -2,12 +2,15 @@
2
2
 
3
3
  CLAUDE.md spells out the pipeline:
4
4
 
5
- CorrelationId → RequestLogging → Security → Session → <module>
5
+ CorrelationId → RequestLogging → GZip → Security → Session → <module>
6
6
  → Tenant (opt-in) → Locale → InertiaLayoutData → app
7
7
 
8
8
  Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
9
9
  DB queries get filtered correctly; CorrelationId must wrap everything so
10
- every log line carries its id. Order matters and a swap is the kind of
10
+ every log line carries its id. GZip sits inside the observability pair so
11
+ those still see every request, but outside everything that produces a body
12
+ — including the /static mount, which is where compression pays off most.
13
+ Order matters and a swap is the kind of
11
14
  regression that breaks production without breaking any happy-path test.
12
15
  ``app.user_middleware`` lists middlewares in execution order (Starlette
13
16
  LIFOs ``add_middleware`` calls, FastAPI surfaces them already reversed).
@@ -22,6 +25,7 @@ from simple_module_hosting.settings import Settings
22
25
  _EXPECTED_MULTI_TENANT = (
23
26
  "CorrelationIdMiddleware",
24
27
  "RequestLoggingMiddleware",
28
+ "GZipMiddleware",
25
29
  "SecurityHeadersMiddleware",
26
30
  "SessionMiddleware",
27
31
  "AuthMiddleware",
@@ -33,6 +37,7 @@ _EXPECTED_MULTI_TENANT = (
33
37
  _EXPECTED_SINGLE_TENANT = (
34
38
  "CorrelationIdMiddleware",
35
39
  "RequestLoggingMiddleware",
40
+ "GZipMiddleware",
36
41
  "SecurityHeadersMiddleware",
37
42
  "SessionMiddleware",
38
43
  "AuthMiddleware",
@@ -0,0 +1,142 @@
1
+ """Serving pre-compressed asset variants.
2
+
3
+ Compressing the same immutable, content-hashed bundle on every request is
4
+ wasted CPU, and on-the-fly compression has to use a fast (worse) level. Files
5
+ compressed once at build time can use the maximum level, and brotli is ~14%
6
+ smaller than gzip on this bundle.
7
+
8
+ The subtle part is the Content-Type: naively serving ``main.js.br`` makes
9
+ Starlette guess the type from the ``.br`` extension and the browser refuses to
10
+ execute it. These tests pin that behaviour.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ from pathlib import Path
17
+
18
+ import brotli
19
+ import httpx
20
+ import pytest
21
+ from fastapi import FastAPI
22
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
23
+
24
+ _JS = b"console.log('hello');" * 200
25
+ _OK = 200
26
+ _JS_TYPE = "text/javascript"
27
+
28
+
29
+ @pytest.fixture
30
+ def asset_dir(tmp_path: Path) -> Path:
31
+ (tmp_path / "app.js").write_bytes(_JS)
32
+ (tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
33
+ # A file with no pre-compressed sibling, to prove the fallback path.
34
+ (tmp_path / "plain.js").write_bytes(_JS)
35
+ return tmp_path
36
+
37
+
38
+ @pytest.fixture
39
+ def client(asset_dir: Path) -> httpx.AsyncClient:
40
+ app = FastAPI()
41
+ app.mount("/static", PrecompressedStaticFiles(directory=asset_dir), name="static")
42
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
43
+
44
+
45
+ class TestPrecompressedServing:
46
+ async def test_serves_gzip_sibling_when_accepted(self, client: httpx.AsyncClient) -> None:
47
+ resp = await client.get(
48
+ "/static/app.js", headers={"Accept-Encoding": "gzip"}, extensions={"trust_env": False}
49
+ )
50
+ assert resp.status_code == _OK
51
+ assert resp.headers["content-encoding"] == "gzip"
52
+
53
+ async def test_content_type_is_the_original_not_the_variant(
54
+ self, client: httpx.AsyncClient
55
+ ) -> None:
56
+ """The classic bug: .gz would otherwise be typed as octet-stream and
57
+ the browser would refuse to execute the script."""
58
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
59
+ assert _JS_TYPE in resp.headers["content-type"]
60
+
61
+ async def test_body_decodes_to_the_original_bytes(self, client: httpx.AsyncClient) -> None:
62
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
63
+ assert resp.content == _JS
64
+
65
+ async def test_vary_header_is_set(self, client: httpx.AsyncClient) -> None:
66
+ """Without Vary, a shared cache could serve a compressed body to a
67
+ client that never asked for one."""
68
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
69
+ assert "accept-encoding" in resp.headers.get("vary", "").lower()
70
+
71
+ async def test_identity_client_gets_uncompressed(self, client: httpx.AsyncClient) -> None:
72
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "identity"})
73
+ assert resp.status_code == _OK
74
+ assert "content-encoding" not in resp.headers
75
+ assert resp.content == _JS
76
+
77
+ async def test_missing_sibling_falls_back_to_original(self, client: httpx.AsyncClient) -> None:
78
+ resp = await client.get("/static/plain.js", headers={"Accept-Encoding": "gzip, br"})
79
+ assert resp.status_code == _OK
80
+ assert "content-encoding" not in resp.headers
81
+ assert resp.content == _JS
82
+
83
+ async def test_unknown_file_still_404s(self, client: httpx.AsyncClient) -> None:
84
+ resp = await client.get("/static/nope.js", headers={"Accept-Encoding": "gzip"})
85
+ assert resp.status_code == 404
86
+
87
+
88
+ class TestBrotliPreference:
89
+ async def test_brotli_preferred_over_gzip(self, tmp_path: Path) -> None:
90
+ """Brotli is ~14% smaller on this bundle, so it wins when both exist."""
91
+ (tmp_path / "app.js").write_bytes(_JS)
92
+ (tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
93
+ (tmp_path / "app.js.br").write_bytes(brotli.compress(_JS))
94
+
95
+ app = FastAPI()
96
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
97
+ async with httpx.AsyncClient(
98
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
99
+ ) as c:
100
+ resp = await c.get("/static/app.js", headers={"Accept-Encoding": "gzip, deflate, br"})
101
+ assert resp.headers["content-encoding"] == "br"
102
+
103
+ async def test_gzip_used_when_brotli_not_accepted(self, tmp_path: Path) -> None:
104
+ (tmp_path / "app.js").write_bytes(_JS)
105
+ (tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
106
+ (tmp_path / "app.js.br").write_bytes(brotli.compress(_JS))
107
+
108
+ app = FastAPI()
109
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
110
+ async with httpx.AsyncClient(
111
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
112
+ ) as c:
113
+ resp = await c.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
114
+ assert resp.headers["content-encoding"] == "gzip"
115
+
116
+
117
+ class TestImmutableCachingPreserved:
118
+ """The existing immutable-caching behaviour must survive the refactor."""
119
+
120
+ async def test_hashed_assets_keep_immutable_cache_control(self, tmp_path: Path) -> None:
121
+ assets = tmp_path / "dist" / "assets"
122
+ assets.mkdir(parents=True)
123
+ (assets / "main-abc123.js").write_bytes(_JS)
124
+
125
+ app = FastAPI()
126
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
127
+ async with httpx.AsyncClient(
128
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
129
+ ) as c:
130
+ resp = await c.get("/static/dist/assets/main-abc123.js")
131
+ assert "immutable" in resp.headers.get("cache-control", "")
132
+
133
+ async def test_non_hashed_paths_do_not_get_immutable(self, tmp_path: Path) -> None:
134
+ (tmp_path / "favicon.ico").write_bytes(b"\x00" * 100)
135
+
136
+ app = FastAPI()
137
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
138
+ async with httpx.AsyncClient(
139
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
140
+ ) as c:
141
+ resp = await c.get("/static/favicon.ico")
142
+ assert "immutable" not in resp.headers.get("cache-control", "")
@@ -0,0 +1,119 @@
1
+ """Proxy-header handling (X-Forwarded-Proto / X-Forwarded-For).
2
+
3
+ Behind a TLS-terminating reverse proxy the app sees plain ``http`` on the
4
+ container socket, so ``request.url.scheme`` is wrong. inertia-python copies
5
+ that scheme into the page object's absolute ``url``; the browser then refuses
6
+ the cross-scheme ``history.pushState`` and login breaks (GH #223).
7
+
8
+ When ``SM_TRUSTED_PROXY`` is set, the host registers uvicorn's
9
+ ``ProxyHeadersMiddleware`` as the outermost middleware so the corrected scheme
10
+ and client IP are visible to every downstream middleware (logging included).
11
+ Left unset (the default) nothing changes — the header is ignored.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import httpx
17
+ import pytest
18
+ from simple_module_hosting.app_builder import create_app
19
+ from simple_module_hosting.settings import Settings
20
+ from simple_module_test.fixtures import _create_all_tables
21
+
22
+ _LOGIN_PATH = "/users/login"
23
+
24
+
25
+ def _names(app) -> tuple[str, ...]:
26
+ return tuple(m.cls.__name__ for m in app.user_middleware)
27
+
28
+
29
+ def _settings(**overrides) -> Settings:
30
+ base = {
31
+ "database_url": "sqlite+aiosqlite:///:memory:",
32
+ "environment": "testing",
33
+ "secret_key": "x" * 32,
34
+ "multi_tenant": False,
35
+ }
36
+ base.update(overrides)
37
+ return Settings(**base)
38
+
39
+
40
+ class TestMiddlewareWiring:
41
+ def test_absent_by_default(self) -> None:
42
+ """No SM_TRUSTED_PROXY → ProxyHeadersMiddleware must not be installed."""
43
+ app = create_app(_settings())
44
+ assert "ProxyHeadersMiddleware" not in _names(app)
45
+
46
+ def test_present_and_outermost_when_configured(self) -> None:
47
+ """ProxyHeaders must run before every other middleware.
48
+
49
+ ``app.user_middleware`` is in execution order (FastAPI reverses the
50
+ LIFO add_middleware stack), so the proxy middleware must be first —
51
+ otherwise RequestLogging would log the proxy's address, and Inertia
52
+ would already have read the wrong scheme.
53
+ """
54
+ app = create_app(_settings(trusted_proxy="*"))
55
+ names = _names(app)
56
+ assert names[0] == "ProxyHeadersMiddleware"
57
+
58
+ def test_trusted_hosts_passed_through(self) -> None:
59
+ """The configured value reaches uvicorn's middleware as trusted_hosts."""
60
+ app = create_app(_settings(trusted_proxy="10.0.0.0/8,127.0.0.1"))
61
+ proxy = next(m for m in app.user_middleware if m.cls.__name__ == "ProxyHeadersMiddleware")
62
+ assert proxy.kwargs["trusted_hosts"] == "10.0.0.0/8,127.0.0.1"
63
+
64
+ def test_surrounding_whitespace_stripped(self) -> None:
65
+ """A stray space must not silently defeat ``*`` trust.
66
+
67
+ uvicorn computes always-trust as ``raw in ("*", ["*"])`` *before*
68
+ stripping, so ``"* "`` would be parsed as a literal host that never
69
+ matches any client — silently re-introducing GH #223 with no error.
70
+ The setting must normalize the value first.
71
+ """
72
+ app = create_app(_settings(trusted_proxy="* "))
73
+ proxy = next(m for m in app.user_middleware if m.cls.__name__ == "ProxyHeadersMiddleware")
74
+ assert proxy.kwargs["trusted_hosts"] == "*"
75
+
76
+ def test_blank_value_disables(self) -> None:
77
+ """Whitespace-only SM_TRUSTED_PROXY is treated as unset (no middleware)."""
78
+ app = create_app(_settings(trusted_proxy=" "))
79
+ assert "ProxyHeadersMiddleware" not in _names(app)
80
+
81
+
82
+ async def _client_for(settings: Settings):
83
+ """Build an app + lifespan-started client mirroring the shared fixtures."""
84
+ app = create_app(settings)
85
+ await _create_all_tables(app.state.sm.db.engine)
86
+ ctx = app.router.lifespan_context(app)
87
+ await ctx.__aenter__()
88
+ transport = httpx.ASGITransport(app=app)
89
+ client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
90
+ return client, ctx
91
+
92
+
93
+ class TestSchemeCorrection:
94
+ @pytest.mark.parametrize(
95
+ ("trusted_proxy", "expected_scheme"),
96
+ [
97
+ ("*", "https"), # trusted proxy → X-Forwarded-Proto honored
98
+ (None, "http"), # no trust configured → header ignored
99
+ ],
100
+ )
101
+ async def test_inertia_page_url_scheme(
102
+ self, trusted_proxy: str | None, expected_scheme: str
103
+ ) -> None:
104
+ """The Inertia page object's absolute url reflects X-Forwarded-Proto.
105
+
106
+ This is the exact thing the browser chokes on: inertia-python sets
107
+ ``"url": str(request.url)``, scheme included, and embeds it in the
108
+ ``data-page`` blob of the rendered HTML.
109
+ """
110
+ client, ctx = await _client_for(_settings(trusted_proxy=trusted_proxy))
111
+ try:
112
+ resp = await client.get(_LOGIN_PATH, headers={"X-Forwarded-Proto": "https"})
113
+ assert resp.status_code == 200
114
+ assert f"{expected_scheme}://testserver{_LOGIN_PATH}" in resp.text
115
+ wrong = "https" if expected_scheme == "http" else "http"
116
+ assert f"{wrong}://testserver{_LOGIN_PATH}" not in resp.text
117
+ finally:
118
+ await client.aclose()
119
+ await ctx.__aexit__(None, None, None)
@@ -0,0 +1,88 @@
1
+ """Responses must be compressed when the client advertises support.
2
+
3
+ The built frontend ships ~139 KB of CSS and ~490 KB of JS. Uncompressed that
4
+ is the dominant cost of a cold page load — far larger than anything on the
5
+ server request path. Compression cuts the CSS alone by ~85%.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ _OK = 200
14
+ _GZIP = "gzip"
15
+ # Below Starlette's default minimum_size, compression is skipped because the
16
+ # gzip framing would cost more than it saves.
17
+ _TINY_BODY_BYTES = 100
18
+
19
+
20
+ class TestResponseCompression:
21
+ async def test_large_response_is_gzipped_when_accepted(
22
+ self, authenticated_client: httpx.AsyncClient
23
+ ) -> None:
24
+ resp = await authenticated_client.get(
25
+ "/api/permissions/", headers={"Accept-Encoding": "gzip"}
26
+ )
27
+ assert resp.status_code == _OK
28
+ # httpx transparently decodes, so assert on the header the server set.
29
+ assert resp.headers.get("content-encoding") == _GZIP
30
+
31
+ async def test_response_is_not_gzipped_when_not_accepted(
32
+ self, authenticated_client: httpx.AsyncClient
33
+ ) -> None:
34
+ """A client that doesn't advertise gzip must still get a usable body."""
35
+ resp = await authenticated_client.get(
36
+ "/api/permissions/", headers={"Accept-Encoding": "identity"}
37
+ )
38
+ assert resp.status_code == _OK
39
+ assert resp.headers.get("content-encoding") != _GZIP
40
+ assert resp.json() is not None
41
+
42
+ async def test_compression_does_not_corrupt_the_payload(
43
+ self, authenticated_client: httpx.AsyncClient
44
+ ) -> None:
45
+ """The decoded body must match what the uncompressed route returns."""
46
+ compressed = await authenticated_client.get(
47
+ "/api/permissions/", headers={"Accept-Encoding": "gzip"}
48
+ )
49
+ plain = await authenticated_client.get(
50
+ "/api/permissions/", headers={"Accept-Encoding": "identity"}
51
+ )
52
+ assert compressed.json() == plain.json()
53
+
54
+ async def test_health_endpoint_still_works(self, client: httpx.AsyncClient) -> None:
55
+ """Small responses fall under the size threshold and pass through."""
56
+ resp = await client.get("/health", headers={"Accept-Encoding": "gzip"})
57
+ assert resp.status_code == _OK
58
+ assert resp.json() is not None
59
+
60
+
61
+ class TestCompressionMiddlewareOrder:
62
+ def test_gzip_runs_inside_correlation_id(self, app) -> None:
63
+ """GZip must sit inside CorrelationId/RequestLogging.
64
+
65
+ Those two set response headers and read request state; compression is
66
+ a transport concern that should wrap only the response body beneath
67
+ them. Starlette's stack is LIFO, so a middleware added *later* is
68
+ outermost — GZip must therefore appear earlier in user_middleware
69
+ than CorrelationId.
70
+ """
71
+ from simple_module_hosting._observability import CorrelationIdMiddleware
72
+ from starlette.middleware.gzip import GZipMiddleware
73
+
74
+ classes = [m.cls for m in app.user_middleware]
75
+ assert GZipMiddleware in classes, "GZipMiddleware is not installed"
76
+ assert classes.index(GZipMiddleware) > classes.index(CorrelationIdMiddleware)
77
+
78
+
79
+ @pytest.mark.parametrize("encoding", ["gzip", "gzip, deflate", "gzip, deflate, br"])
80
+ async def test_common_browser_accept_encodings(
81
+ authenticated_client: httpx.AsyncClient, encoding: str
82
+ ) -> None:
83
+ """Real browsers send several forms; all must yield a valid response."""
84
+ resp = await authenticated_client.get(
85
+ "/api/permissions/", headers={"Accept-Encoding": encoding}
86
+ )
87
+ assert resp.status_code == _OK
88
+ assert resp.json() is not None