simple-module-hosting 0.0.3__tar.gz → 0.0.5__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 (44) hide show
  1. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/pyproject.toml +6 -3
  3. simple_module_hosting-0.0.5/simple_module_hosting/__main__.py +14 -0
  4. simple_module_hosting-0.0.5/simple_module_hosting/_host_services.py +21 -0
  5. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/_inertia_setup.py +15 -2
  6. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/_phase_helpers.py +36 -1
  7. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/app_builder.py +34 -26
  8. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/host_cli.py +7 -2
  9. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/middleware.py +12 -2
  10. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_app.py +4 -26
  11. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_host_cli.py +22 -0
  12. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/.gitignore +0 -0
  13. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/LICENSE +0 -0
  14. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/README.md +0 -0
  15. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/__init__.py +0 -0
  16. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/_error_handlers.py +0 -0
  17. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/_hydrate_step.py +0 -0
  18. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/_inertia_shared.py +0 -0
  19. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/_observability.py +0 -0
  20. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/bootstrap_settings.py +0 -0
  21. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/health.py +0 -0
  22. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/host_settings.py +0 -0
  23. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/i18n_deps.py +0 -0
  24. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/i18n_manifest.py +0 -0
  25. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/i18n_middleware.py +0 -0
  26. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/inertia_deps.py +0 -0
  27. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/inertia_utils.py +0 -0
  28. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/logging.py +0 -0
  29. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/manifest.py +0 -0
  30. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/migrations.py +0 -0
  31. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/permissions.py +0 -0
  32. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/py.typed +0 -0
  33. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/redirects.py +0 -0
  34. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/simple_module_hosting/settings.py +0 -0
  35. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_health.py +0 -0
  36. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_hosting_permissions.py +0 -0
  37. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_i18n_manifest.py +0 -0
  38. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_inertia_i18n_shared_props.py +0 -0
  39. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_locale_middleware.py +0 -0
  40. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_logging.py +0 -0
  41. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_settings_i18n.py +0 -0
  42. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_settings_secrets.py +0 -0
  43. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/tests/test_tenant_middleware.py +0 -0
  44. {simple_module_hosting-0.0.3 → simple_module_hosting-0.0.5}/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.3
3
+ Version: 0.0.5
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.3
30
- Requires-Dist: simple-module-db==0.0.3
29
+ Requires-Dist: simple-module-core==0.0.5
30
+ Requires-Dist: simple-module-db==0.0.5
31
31
  Requires-Dist: starlette>=0.44
32
32
  Requires-Dist: tomlkit>=0.13
33
33
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.3"
3
+ version = "0.0.5"
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,13 +26,16 @@ dependencies = [
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
28
  "jinja2>=3.1",
29
- "simple_module_core==0.0.3",
30
- "simple_module_db==0.0.3",
29
+ "simple_module_core==0.0.5",
30
+ "simple_module_db==0.0.5",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "uvicorn[standard]>=0.34",
34
34
  ]
35
35
 
36
+ [project.scripts]
37
+ sm-host = "simple_module_hosting.host_cli:app"
38
+
36
39
  [project.entry-points."simple_module_cli.cli_plugins"]
37
40
  host = "simple_module_hosting.host_cli:app"
38
41
 
@@ -0,0 +1,14 @@
1
+ """Module entry point: ``python -m simple_module_hosting`` invokes the host CLI.
2
+
3
+ Without this, ``python -m simple_module_hosting.host_cli`` would import the
4
+ module without running the Typer app — silently no-op'ing commands like
5
+ ``gen-pages``. Provides the same Typer ``app`` callable that the
6
+ ``simple_module_cli.cli_plugins`` entry point exposes as ``sm host``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from simple_module_hosting.host_cli import app
12
+
13
+ if __name__ == "__main__":
14
+ app()
@@ -0,0 +1,21 @@
1
+ """``_HostServices`` container exposed on ``app.state.host``.
2
+
3
+ Module-scope so the type is stable across ``create_app`` calls — tests
4
+ that build multiple apps in one process can ``isinstance``-check
5
+ ``app.state.host`` against the same class object.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from simple_module_hosting.host_settings import HostSettings
13
+
14
+ __all__ = ["_HostServices"]
15
+
16
+
17
+ @dataclass
18
+ class _HostServices:
19
+ """Container for host-level services exposed on ``app.state.host``."""
20
+
21
+ settings: HostSettings
@@ -57,10 +57,23 @@ def setup_inertia(
57
57
 
58
58
  templates = Jinja2Templates(directory=directories)
59
59
 
60
+ # fastapi-inertia only switches to the asset manifest when environment
61
+ # equals the literal string "production". Anything else (staging, qa,
62
+ # ...) would render a /main.tsx <script> tag served by the SPA fallback
63
+ # as text/html, breaking module loading. Normalize:
64
+ # * `development`/`testing` → keep the dev-server path (Vite serves
65
+ # /main.tsx directly).
66
+ # * Anything else (staging, production, qa, ...) → use the production
67
+ # manifest so built assets are referenced.
68
+ from simple_module_core.environments import NON_PROD_ENVIRONMENTS
69
+
70
+ use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
71
+ inertia_environment = "development" if use_dev_server else "production"
72
+
60
73
  inertia_config = InertiaConfig(
61
- environment=settings.environment,
74
+ environment=inertia_environment,
62
75
  version=_INERTIA_VERSION,
63
- dev_url=settings.vite_dev_url if settings.is_development else "",
76
+ dev_url=settings.vite_dev_url if use_dev_server else "",
64
77
  templates=templates,
65
78
  root_template_filename=_ROOT_TEMPLATE_FILENAME,
66
79
  entrypoint_filename=_ENTRYPOINT_FILENAME,
@@ -11,7 +11,8 @@ import logging
11
11
  from pathlib import Path
12
12
  from typing import TYPE_CHECKING
13
13
 
14
- from fastapi import FastAPI
14
+ from fastapi import APIRouter, FastAPI
15
+ from fastapi.routing import APIRoute
15
16
  from fastapi.staticfiles import StaticFiles
16
17
  from inertia import (
17
18
  InertiaVersionConflictException,
@@ -158,3 +159,37 @@ def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]
158
159
  )
159
160
  )
160
161
  return diagnostics
162
+
163
+
164
+ def wire_module_routes(app: FastAPI, module) -> None:
165
+ """Attach a module's API + view routers to ``app`` using its Meta prefixes.
166
+
167
+ The single canonical implementation so ``create_app`` and the test harness
168
+ in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains
169
+ a new router type.
170
+
171
+ Bare-prefix view routes (``view_prefix="/foo"`` + ``@router.get("/")``)
172
+ are also mounted at the trailing-slash-less form ``"/foo"``. Without this,
173
+ FastAPI's ``redirect_slashes=True`` fires a 307 to ``"/foo/"``, which
174
+ clients like httpx strip ``X-Inertia`` from on follow — turning every
175
+ Inertia navigation into a broken HTML response. Cloning the route at the
176
+ bare-prefix path serves the same handler directly, no redirect.
177
+ """
178
+ api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
179
+ view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
180
+ module.register_routes(api_router, view_router)
181
+ if module.meta.view_prefix:
182
+ bare_target = f"{module.meta.view_prefix}/"
183
+ for route in list(view_router.routes):
184
+ if isinstance(route, APIRoute) and route.path == bare_target:
185
+ view_router.add_api_route(
186
+ "",
187
+ route.endpoint,
188
+ methods=list(route.methods or {"GET"}),
189
+ response_model=route.response_model,
190
+ include_in_schema=False,
191
+ dependencies=route.dependencies,
192
+ name=f"{route.name}__bare",
193
+ )
194
+ app.include_router(api_router)
195
+ app.include_router(view_router)
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import inspect
5
6
  import logging
6
7
  import os
7
8
  from collections.abc import AsyncGenerator
8
9
  from contextlib import asynccontextmanager
9
10
  from pathlib import Path
10
11
 
11
- from fastapi import APIRouter, FastAPI
12
+ from fastapi import FastAPI
12
13
  from fastapi.staticfiles import StaticFiles
13
14
  from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
14
15
  from simple_module_core.discovery import discover_modules, topological_sort
@@ -21,14 +22,17 @@ from simple_module_core.services import Services
21
22
  from simple_module_db.listeners import register_listeners
22
23
  from simple_module_db.session import init_db
23
24
 
25
+ from simple_module_hosting._host_services import _HostServices
24
26
  from simple_module_hosting._inertia_setup import setup_inertia
25
27
  from simple_module_hosting._phase_helpers import (
26
28
  check_settings_registration,
27
29
  install_middleware,
28
30
  mount_module_static_dirs,
29
31
  register_exception_handlers,
32
+ wire_module_routes,
30
33
  )
31
34
  from simple_module_hosting.health import router as health_router
35
+ from simple_module_hosting.host_settings import HostSettings
32
36
  from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
33
37
  from simple_module_hosting.migrations import check_migrations
34
38
  from simple_module_hosting.settings import Settings
@@ -44,39 +48,50 @@ _STATIC_DIR_NAME = "static"
44
48
  _ENV_PROJECT_ROOT = "SM_PROJECT_ROOT"
45
49
 
46
50
 
51
+ _PROJECT_ROOT_SENTINELS = ("pyproject.toml", ".env", "alembic.ini")
52
+
53
+
47
54
  def _resolve_project_root() -> Path:
48
55
  """Return the project root directory.
49
56
 
50
- Prefers the ``SM_PROJECT_ROOT`` environment variable (set by
51
- ``host/main.py``) so the framework works even when installed from a
52
- wheel into ``site-packages`` — in that layout the fallback walk-up
53
- below would escape the package into ``site-packages/..`` and miss
54
- ``host/static`` entirely.
57
+ Prefers the ``SM_PROJECT_ROOT`` environment variable when set.
55
58
 
56
- Falls back to ``parents[3]`` for the workspace-install dev loop
57
- (simple_module_hosting/ hosting/ framework/ → project root).
59
+ Otherwise walks up from the current working directory looking for a
60
+ project sentinel (``pyproject.toml``, ``.env`` or ``alembic.ini``). This
61
+ works whether the framework is installed as a wheel into ``site-packages``
62
+ or run from a workspace clone.
63
+
64
+ Falls back to ``parents[3]`` for the in-tree dev loop only when the walk
65
+ finds nothing — which still keeps ``framework/`` users working without
66
+ setting the env var explicitly.
58
67
  """
59
68
  override = os.environ.get(_ENV_PROJECT_ROOT)
60
69
  if override:
61
70
  return Path(override)
71
+ cwd = Path.cwd().resolve()
72
+ for candidate in (cwd, *cwd.parents):
73
+ if any((candidate / s).exists() for s in _PROJECT_ROOT_SENTINELS):
74
+ return candidate
62
75
  return Path(__file__).resolve().parents[3]
63
76
 
64
77
 
65
78
  _PROJECT_ROOT = _resolve_project_root()
66
79
 
67
80
 
68
- def wire_module_routes(app: FastAPI, module) -> None:
69
- """Attach a module's API + view routers to ``app`` using its Meta prefixes.
81
+ def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None:
82
+ """Dispatch to ``mod.register_event_handlers`` with or without ``app``.
70
83
 
71
- The single canonical implementation so ``create_app`` and the test harness
72
- in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains
73
- a new router type.
84
+ Back-compat shim for modules that still override the one-arg form
85
+ ``(self, bus)``; passing ``app=`` to those crashes.
74
86
  """
75
- api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
76
- view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
77
- module.register_routes(api_router, view_router)
78
- app.include_router(api_router)
79
- app.include_router(view_router)
87
+ sig = inspect.signature(mod.register_event_handlers)
88
+ accepts_app = "app" in sig.parameters or any(
89
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
90
+ )
91
+ if accepts_app:
92
+ mod.register_event_handlers(event_bus, app=app)
93
+ else:
94
+ mod.register_event_handlers(event_bus)
80
95
 
81
96
 
82
97
  def create_app(settings: Settings | None = None) -> FastAPI:
@@ -198,18 +213,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
198
213
  # AST plugin-free while still hitting the real helper at runtime.
199
214
  if hasattr(app.state, "settings"):
200
215
  import importlib
201
- from dataclasses import dataclass as _dataclass
202
-
203
- from simple_module_hosting.host_settings import HostSettings
204
216
 
205
217
  _register_module_settings = importlib.import_module(
206
218
  "settings.registration"
207
219
  ).register_module_settings
208
220
 
209
- @_dataclass
210
- class _HostServices:
211
- settings: HostSettings
212
-
213
221
  _register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
214
222
 
215
223
  if settings.is_development:
@@ -222,7 +230,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
222
230
  mod.register_menu_items(menu_registry)
223
231
  mod.register_permissions(perm_registry)
224
232
  mod.register_feature_flags(ff_registry)
225
- mod.register_event_handlers(event_bus)
233
+ _register_event_handlers(mod, event_bus, app)
226
234
  mod.register_health_checks(health_registry)
227
235
 
228
236
  logger.info(
@@ -45,8 +45,9 @@ def gen_pages(
45
45
  modules = discover_modules()
46
46
  written = write_module_pages_manifest(modules, host_dir)
47
47
  typer.echo(
48
- f"Wrote {written['manifest'].name}, {written['generated'].name}, "
49
- f"{written['css'].name} to {host_dir}"
48
+ f"Module pages manifest: {len(modules)} module(s) "
49
+ f"{written['manifest'].name}, {written['generated'].name}, "
50
+ f"{written['css'].name} in {host_dir}"
50
51
  )
51
52
 
52
53
 
@@ -115,3 +116,7 @@ def sync_js_deps(
115
116
  return
116
117
  result = subprocess.run(cmd, cwd=repo_root, check=False)
117
118
  raise typer.Exit(code=result.returncode)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ app()
@@ -45,7 +45,10 @@ _HEADER_HSTS = "Strict-Transport-Security"
45
45
  # Security response header values
46
46
  _XCTO_NOSNIFF = "nosniff"
47
47
  _XFO_SAMEORIGIN = "SAMEORIGIN"
48
- _XXSS_BLOCK = "1; mode=block"
48
+ # OWASP/MDN now recommend disabling the legacy XSS auditor — older
49
+ # browser implementations introduced reflected-XSS vectors of their own.
50
+ # Modern protection comes from the CSP below (strict-dynamic + nonces).
51
+ _XXSS_DISABLED = "0"
49
52
  _REFERRER_STRICT_ORIGIN = "strict-origin-when-cross-origin"
50
53
 
51
54
  __all__ = [
@@ -79,6 +82,11 @@ class SecurityHeadersMiddleware:
79
82
  "img-src 'self' data: blob:; "
80
83
  "font-src 'self' https://fonts.gstatic.com data:; "
81
84
  "connect-src 'self'; "
85
+ # Allow blob: workers — MapLibre, comlink, web-tree-sitter and most
86
+ # WASM libs ship their worker as a Blob URL. `child-src` is the
87
+ # legacy fallback some browsers consult before `worker-src`.
88
+ "worker-src 'self' blob:; "
89
+ "child-src 'self' blob:; "
82
90
  "frame-ancestors 'self'; "
83
91
  "base-uri 'self'; "
84
92
  "form-action 'self'"
@@ -104,6 +112,8 @@ class SecurityHeadersMiddleware:
104
112
  "img-src 'self' data: blob:; "
105
113
  "font-src 'self' https://fonts.gstatic.com data:; "
106
114
  f"connect-src 'self' {vite_dev_url} {ws_url}; "
115
+ "worker-src 'self' blob:; "
116
+ "child-src 'self' blob:; "
107
117
  "frame-ancestors 'self'; "
108
118
  "base-uri 'self'; "
109
119
  "form-action 'self'"
@@ -130,7 +140,7 @@ class SecurityHeadersMiddleware:
130
140
  headers = MutableHeaders(scope=message)
131
141
  headers[_HEADER_X_CONTENT_TYPE_OPTIONS] = _XCTO_NOSNIFF
132
142
  headers[_HEADER_X_FRAME_OPTIONS] = _XFO_SAMEORIGIN
133
- headers[_HEADER_X_XSS_PROTECTION] = _XXSS_BLOCK
143
+ headers[_HEADER_X_XSS_PROTECTION] = _XXSS_DISABLED
134
144
  headers[_HEADER_REFERRER_POLICY] = _REFERRER_STRICT_ORIGIN
135
145
  if self.csp:
136
146
  headers[_HEADER_CSP] = self.csp
@@ -2,8 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections import defaultdict
6
-
7
5
  import httpx
8
6
  import pytest
9
7
  from fastapi import FastAPI
@@ -29,12 +27,11 @@ class TestCreateApp:
29
27
 
30
28
  async def test_modules_enabled_limits_loaded_modules(self, settings: Settings):
31
29
  """Host respects settings.modules_enabled — only listed modules contribute routes."""
32
- # Only Auth should be loaded; Products + Dashboard routes must be absent.
30
+ # Only Auth should be loaded; Dashboard routes must be absent.
33
31
  restricted = settings.model_copy(update={"modules_enabled": ["Auth"]})
34
32
  app = create_app(restricted)
35
33
  paths: set[str] = {str(r.path) for r in app.routes if hasattr(r, "path")}
36
34
  # Auth is now contracts-only, so it has no routes — only health remains.
37
- assert not any(p.startswith("/api/products") for p in paths)
38
35
  assert "/dashboard" not in paths
39
36
 
40
37
  async def test_module_static_mounts_become_app_routes(
@@ -117,9 +114,6 @@ class TestRouteRegistration:
117
114
  assert "/health/live" in route_paths
118
115
  assert "/health/ready" in route_paths
119
116
 
120
- assert "/api/products/" in route_paths
121
- assert "/api/products/{product_id}" in route_paths
122
-
123
117
  # Users module owns login, register, etc. Auth module is contracts-only.
124
118
  assert "/users/login" in route_paths
125
119
 
@@ -127,19 +121,8 @@ class TestRouteRegistration:
127
121
  # landing page at "/" is owned by the host and added in host/main.py,
128
122
  # which the create_app fixture doesn't run.
129
123
  assert "/dashboard/" in route_paths
130
-
131
- async def test_products_api_methods(self, app: FastAPI):
132
- """Products endpoints should support the correct HTTP methods."""
133
- routes_by_path: dict[str, set[str]] = defaultdict(set)
134
- for route in app.routes:
135
- if hasattr(route, "path") and hasattr(route, "methods"):
136
- routes_by_path[route.path].update(route.methods)
137
-
138
- assert "GET" in routes_by_path.get("/api/products/", set())
139
- assert "POST" in routes_by_path.get("/api/products/", set())
140
- assert "GET" in routes_by_path.get("/api/products/{product_id}", set())
141
- assert "PUT" in routes_by_path.get("/api/products/{product_id}", set())
142
- assert "DELETE" in routes_by_path.get("/api/products/{product_id}", set())
124
+ # Bare-prefix alias — see wire_module_routes for the X-Inertia rationale.
125
+ assert "/dashboard" in route_paths
143
126
 
144
127
 
145
128
  class TestProtectedPages:
@@ -148,18 +131,13 @@ class TestProtectedPages:
148
131
  assert resp.status_code == 302
149
132
  assert "/users/login" in resp.headers["location"]
150
133
 
151
- async def test_products_page_redirects_unauthenticated(self, client: httpx.AsyncClient):
152
- resp = await client.get("/products/", follow_redirects=False)
153
- assert resp.status_code == 302
154
- assert "/users/login" in resp.headers["location"]
155
-
156
134
 
157
135
  class TestSecurityHeaders:
158
136
  async def test_security_headers_present(self, client: httpx.AsyncClient):
159
137
  resp = await client.get("/health")
160
138
  assert resp.headers["x-content-type-options"] == "nosniff"
161
139
  assert resp.headers["x-frame-options"] == "SAMEORIGIN"
162
- assert resp.headers["x-xss-protection"] == "1; mode=block"
140
+ assert resp.headers["x-xss-protection"] == "0"
163
141
  assert resp.headers["referrer-policy"] == "strict-origin-when-cross-origin"
164
142
 
165
143
 
@@ -2,8 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import subprocess
6
+ import sys
5
7
  from pathlib import Path
6
8
 
9
+ import pytest
7
10
  import typer
8
11
  from simple_module_hosting.host_cli import app
9
12
  from typer.testing import CliRunner
@@ -26,3 +29,22 @@ def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None:
26
29
  result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")])
27
30
  assert result.exit_code != 0
28
31
  assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()
32
+
33
+
34
+ @pytest.mark.parametrize(
35
+ "module_target",
36
+ ["simple_module_hosting", "simple_module_hosting.host_cli"],
37
+ )
38
+ def test_python_dash_m_invocation_runs_cli(module_target: str) -> None:
39
+ """Both ``python -m simple_module_hosting`` and ``...host_cli`` must invoke
40
+ the Typer app — without ``__main__.py`` / a ``__name__ == "__main__"`` block
41
+ these silently no-op'd, breaking the documented ``gen-pages`` workflow.
42
+ """
43
+ result = subprocess.run(
44
+ [sys.executable, "-m", module_target, "--help"],
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ )
49
+ assert result.returncode == 0, result.stderr
50
+ assert "gen-pages" in result.stdout