simple-module-hosting 0.0.13__tar.gz → 0.0.14__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 (51) hide show
  1. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/pyproject.toml +3 -3
  3. simple_module_hosting-0.0.14/tests/test_check_migrations.py +84 -0
  4. simple_module_hosting-0.0.14/tests/test_lifespan_order.py +85 -0
  5. simple_module_hosting-0.0.14/tests/test_middleware_order.py +79 -0
  6. simple_module_hosting-0.0.14/tests/test_redirects.py +129 -0
  7. simple_module_hosting-0.0.14/tests/test_session_cookie_security.py +54 -0
  8. simple_module_hosting-0.0.14/tests/test_strict_discovery_wiring.py +116 -0
  9. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/.gitignore +0 -0
  10. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/LICENSE +0 -0
  11. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/README.md +0 -0
  12. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/__init__.py +0 -0
  13. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/__main__.py +0 -0
  14. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_error_handlers.py +0 -0
  15. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_host_services.py +0 -0
  16. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_hydrate_step.py +0 -0
  17. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_inertia_setup.py +0 -0
  18. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_inertia_shared.py +0 -0
  19. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_observability.py +0 -0
  20. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/_phase_helpers.py +0 -0
  21. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/app_builder.py +0 -0
  22. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/bootstrap_settings.py +0 -0
  23. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/health.py +0 -0
  24. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/host_cli.py +0 -0
  25. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/host_settings.py +0 -0
  26. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/i18n_deps.py +0 -0
  27. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/i18n_manifest.py +0 -0
  28. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/i18n_middleware.py +0 -0
  29. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/inertia_deps.py +0 -0
  30. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/inertia_utils.py +0 -0
  31. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/logging.py +0 -0
  32. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/manifest.py +0 -0
  33. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/middleware.py +0 -0
  34. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/migrations.py +0 -0
  35. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/permissions.py +0 -0
  36. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/py.typed +0 -0
  37. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/redirects.py +0 -0
  38. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/simple_module_hosting/settings.py +0 -0
  39. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_app.py +0 -0
  40. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_health.py +0 -0
  41. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_host_cli.py +0 -0
  42. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_hosting_permissions.py +0 -0
  43. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_i18n_manifest.py +0 -0
  44. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_inertia_i18n_shared_props.py +0 -0
  45. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_locale_middleware.py +0 -0
  46. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_logging.py +0 -0
  47. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_manifest.py +0 -0
  48. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_settings_i18n.py +0 -0
  49. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_settings_secrets.py +0 -0
  50. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/tests/test_tenant_middleware.py +0 -0
  51. {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.14}/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.13
3
+ Version: 0.0.14
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.13
30
- Requires-Dist: simple-module-db==0.0.13
29
+ Requires-Dist: simple-module-core==0.0.14
30
+ Requires-Dist: simple-module-db==0.0.14
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.13"
3
+ version = "0.0.14"
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.13",
30
- "simple_module_db==0.0.13",
29
+ "simple_module_core==0.0.14",
30
+ "simple_module_db==0.0.14",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "uvicorn[standard]>=0.34",
@@ -0,0 +1,84 @@
1
+ """Boot-time migration-drift check.
2
+
3
+ ``check_migrations`` is called from inside the app's lifespan in
4
+ ``app_builder.py``. If it loses its teeth — e.g. someone refactors and forgets
5
+ to raise — a behind-head DB would silently boot and produce confusing missing-
6
+ column errors at runtime. This file pins:
7
+
8
+ * DB at head → returns the status dict.
9
+ * DB behind head → raises ``RuntimeError`` with a helpful message.
10
+ * No alembic config available → returns the "no migrations" sentinel.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import pytest
16
+ from simple_module_hosting.migrations import check_migrations, resolve_head_revision
17
+ from sqlalchemy import text
18
+ from sqlalchemy.ext.asyncio import create_async_engine
19
+
20
+
21
+ @pytest.mark.anyio
22
+ async def test_returns_no_migrations_sentinel_when_alembic_ini_absent(tmp_path):
23
+ """Pointing at a missing alembic.ini → status dict, no exception."""
24
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
25
+ try:
26
+ result = await check_migrations(engine, alembic_ini_path=str(tmp_path / "missing.ini"))
27
+ finally:
28
+ await engine.dispose()
29
+ assert result["current_revision"] is None
30
+ assert result["head_revision"] is None
31
+ assert result["is_current"] is True
32
+
33
+
34
+ @pytest.mark.anyio
35
+ async def test_db_at_head_returns_current_status():
36
+ """Stamp the in-memory DB at head and check_migrations should pass cleanly."""
37
+ head = resolve_head_revision()
38
+ if head is None:
39
+ pytest.skip("Repository alembic.ini not resolvable from cwd")
40
+
41
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
42
+ try:
43
+ async with engine.begin() as conn:
44
+ await conn.execute(
45
+ text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL PRIMARY KEY)")
46
+ )
47
+ await conn.execute(
48
+ text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
49
+ {"v": head},
50
+ )
51
+ result = await check_migrations(engine)
52
+ finally:
53
+ await engine.dispose()
54
+
55
+ assert result["current_revision"] == head
56
+ assert result["head_revision"] == head
57
+ assert result["is_current"] is True
58
+
59
+
60
+ @pytest.mark.anyio
61
+ async def test_unstamped_db_raises_drift_error():
62
+ """An empty in-memory DB (no alembic_version row) must hard-fail."""
63
+ if resolve_head_revision() is None:
64
+ pytest.skip("Repository alembic.ini not resolvable from cwd")
65
+
66
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
67
+ try:
68
+ with pytest.raises(RuntimeError, match="revision\\(s\\) behind"):
69
+ await check_migrations(engine)
70
+ finally:
71
+ await engine.dispose()
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_resolve_head_revision_consistent():
76
+ """Sanity: ``resolve_head_revision`` returns the same string twice in a row.
77
+
78
+ The function is invoked from both the cached fixture in conftest and the
79
+ real lifespan; if it ever became non-deterministic the cached value would
80
+ diverge from the live one and the migration check would lie.
81
+ """
82
+ a = resolve_head_revision()
83
+ b = resolve_head_revision()
84
+ assert a == b
@@ -0,0 +1,85 @@
1
+ """Verify ``create_app``'s lifespan walks modules forward and backward.
2
+
3
+ CLAUDE.md says ``on_startup`` runs in topological order and ``on_shutdown``
4
+ in reverse — a regression here means a dependent module's startup hook runs
5
+ before its dependency's hook is ready (or, on shutdown, the dependency tears
6
+ down resources while the dependent is still using them).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+ from unittest.mock import patch
13
+
14
+ import pytest
15
+ from simple_module_core.module import ModuleBase, ModuleMeta
16
+ from simple_module_hosting.app_builder import create_app
17
+ from simple_module_hosting.settings import Settings
18
+
19
+ if TYPE_CHECKING:
20
+ from fastapi import FastAPI
21
+
22
+
23
+ _calls: list[str] = []
24
+
25
+
26
+ class _TrackingModule(ModuleBase):
27
+ """Records its own name into the module-level _calls list on each hook.
28
+
29
+ Subclasses set their own ``meta`` so each one is identifiable inside
30
+ the dependency graph; the hook bodies stay on the base.
31
+ """
32
+
33
+ async def on_startup(self, app: FastAPI) -> None: # type: ignore[override]
34
+ _calls.append(f"start:{self.meta.name}")
35
+
36
+ async def on_shutdown(self, app: FastAPI) -> None: # type: ignore[override]
37
+ _calls.append(f"stop:{self.meta.name}")
38
+
39
+
40
+ class _ModA(_TrackingModule):
41
+ meta = ModuleMeta(name="A")
42
+
43
+
44
+ class _ModB(_TrackingModule):
45
+ meta = ModuleMeta(name="B", depends_on=["A"])
46
+
47
+
48
+ class _ModC(_TrackingModule):
49
+ meta = ModuleMeta(name="C", depends_on=["B"])
50
+
51
+
52
+ @pytest.mark.anyio
53
+ async def test_lifespan_startup_forward_shutdown_reverse(monkeypatch) -> None:
54
+ """Three modules A→B→C: startup must be A,B,C and shutdown C,B,A.
55
+
56
+ We stub ``discover_modules`` rather than registering real entry points
57
+ so this test stays hermetic; the topological-sort layer is exercised
58
+ end-to-end by other tests already.
59
+ """
60
+ _calls.clear()
61
+ instances: list[ModuleBase] = [_ModA(), _ModB(), _ModC()]
62
+
63
+ async def _no_migration_check(engine, *args, **kwargs):
64
+ return {"current_revision": None, "head_revision": None, "is_current": True}
65
+
66
+ with (
67
+ patch("simple_module_hosting.app_builder.discover_modules", return_value=instances),
68
+ patch("simple_module_hosting.app_builder.check_migrations", _no_migration_check),
69
+ ):
70
+ settings = Settings(
71
+ database_url="sqlite+aiosqlite:///:memory:",
72
+ environment="testing",
73
+ secret_key="x" * 32,
74
+ multi_tenant=False,
75
+ )
76
+ app = create_app(settings)
77
+
78
+ ctx = app.router.lifespan_context(app)
79
+ await ctx.__aenter__()
80
+ await ctx.__aexit__(None, None, None)
81
+
82
+ starts = [c for c in _calls if c.startswith("start:")]
83
+ stops = [c for c in _calls if c.startswith("stop:")]
84
+ assert starts == ["start:A", "start:B", "start:C"], _calls
85
+ assert stops == ["stop:C", "stop:B", "stop:A"], _calls
@@ -0,0 +1,79 @@
1
+ """Pin the documented middleware execution order.
2
+
3
+ CLAUDE.md spells out the pipeline:
4
+
5
+ CorrelationId → RequestLogging → Security → Session → <module>
6
+ → Tenant (opt-in) → Locale → InertiaLayoutData → app
7
+
8
+ Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
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
11
+ regression that breaks production without breaking any happy-path test.
12
+ ``app.user_middleware`` lists middlewares in execution order (Starlette
13
+ LIFOs ``add_middleware`` calls, FastAPI surfaces them already reversed).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+ from simple_module_hosting.app_builder import create_app
20
+ from simple_module_hosting.settings import Settings
21
+
22
+ _EXPECTED_MULTI_TENANT = (
23
+ "CorrelationIdMiddleware",
24
+ "RequestLoggingMiddleware",
25
+ "SecurityHeadersMiddleware",
26
+ "SessionMiddleware",
27
+ "AuthMiddleware",
28
+ "TenantMiddleware",
29
+ "LocaleMiddleware",
30
+ "InertiaLayoutDataMiddleware",
31
+ )
32
+
33
+ _EXPECTED_SINGLE_TENANT = (
34
+ "CorrelationIdMiddleware",
35
+ "RequestLoggingMiddleware",
36
+ "SecurityHeadersMiddleware",
37
+ "SessionMiddleware",
38
+ "AuthMiddleware",
39
+ "LocaleMiddleware",
40
+ "InertiaLayoutDataMiddleware",
41
+ )
42
+
43
+
44
+ def _names(app) -> tuple[str, ...]:
45
+ return tuple(m.cls.__name__ for m in app.user_middleware)
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ ("multi_tenant", "expected"),
50
+ [(True, _EXPECTED_MULTI_TENANT), (False, _EXPECTED_SINGLE_TENANT)],
51
+ )
52
+ def test_middleware_pipeline_order(multi_tenant: bool, expected: tuple[str, ...]) -> None:
53
+ settings = Settings(
54
+ database_url="sqlite+aiosqlite:///:memory:",
55
+ environment="testing",
56
+ secret_key="x" * 32,
57
+ multi_tenant=multi_tenant,
58
+ )
59
+ app = create_app(settings)
60
+ assert _names(app) == expected, (
61
+ "Middleware pipeline order drifted from CLAUDE.md. "
62
+ f"Got {_names(app)!r}, expected {expected!r}."
63
+ )
64
+
65
+
66
+ def test_tenant_middleware_absent_when_disabled() -> None:
67
+ """``multi_tenant=False`` must not register TenantMiddleware at all.
68
+
69
+ Just toggling off the header would still leak the DB context-var setter
70
+ onto every request; the middleware itself is what must vanish.
71
+ """
72
+ settings = Settings(
73
+ database_url="sqlite+aiosqlite:///:memory:",
74
+ environment="testing",
75
+ secret_key="x" * 32,
76
+ multi_tenant=False,
77
+ )
78
+ app = create_app(settings)
79
+ assert "TenantMiddleware" not in _names(app)
@@ -0,0 +1,129 @@
1
+ """Direct unit tests for ``safe_referer_or_root``.
2
+
3
+ The helper is the only barrier between an attacker-controlled ``Referer`` and
4
+ a 303 redirect back to that URL. The existing test surface only goes through
5
+ ``/i18n/set-locale``, which exercises a handful of vectors. This file pins the
6
+ contract directly with adversarial inputs that the integration test set didn't
7
+ reach (``javascript:``, embedded ``@`` userinfo, ``\\evil.example``,
8
+ CRLF-injection attempts, mixed-case schemes, fragments).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from types import SimpleNamespace
14
+
15
+ import pytest
16
+ from simple_module_hosting.redirects import safe_referer_or_root
17
+
18
+
19
+ def _make_request(*, referer: str | None = None, scheme: str = "http", host: str = "testserver"):
20
+ """Return a minimal duck-typed object matching what safe_referer_or_root reads.
21
+
22
+ The real helper only touches ``request.headers.get("referer")`` and
23
+ ``request.url.scheme`` / ``request.url.netloc`` — a SimpleNamespace beats
24
+ spinning up a Starlette Request just to validate URL parsing.
25
+ """
26
+ headers: dict[str, str] = {}
27
+ if referer is not None:
28
+ headers["referer"] = referer
29
+ return SimpleNamespace(
30
+ headers=headers,
31
+ url=SimpleNamespace(scheme=scheme, netloc=host),
32
+ )
33
+
34
+
35
+ class TestSafeRefererBasics:
36
+ def test_no_referer_returns_root(self) -> None:
37
+ assert safe_referer_or_root(_make_request()) == "/"
38
+
39
+ def test_empty_string_referer_returns_root(self) -> None:
40
+ assert safe_referer_or_root(_make_request(referer="")) == "/"
41
+
42
+ def test_same_origin_relative_path_preserved(self) -> None:
43
+ assert safe_referer_or_root(_make_request(referer="/dashboard")) == "/dashboard"
44
+
45
+ def test_same_origin_absolute_url_collapses_to_path(self) -> None:
46
+ req = _make_request(referer="http://testserver/products?q=pen")
47
+ assert safe_referer_or_root(req) == "/products?q=pen"
48
+
49
+
50
+ class TestSafeRefererBlocksHostedirects:
51
+ """Every input here is an attempt to redirect off-site.
52
+
53
+ A regression that returns the input verbatim is a reflected open-redirect.
54
+ """
55
+
56
+ @pytest.mark.parametrize(
57
+ "malicious",
58
+ [
59
+ "https://evil.example/steal",
60
+ "http://evil.example/x",
61
+ "//evil.example/x",
62
+ "//evil.example",
63
+ r"\\evil.example/x", # backslash-prefixed — some browsers normalize
64
+ "http://testserver.evil.example/", # suffix-confusion
65
+ "http://evil.example@testserver/", # userinfo trick: host is "evil.example"
66
+ "javascript:alert(1)",
67
+ "data:text/html,<script>alert(1)</script>",
68
+ "vbscript:msgbox(1)",
69
+ "FILE:///etc/passwd",
70
+ "HTTPS://EVIL.EXAMPLE/",
71
+ "not-a-path",
72
+ " ",
73
+ "ftp://evil.example/",
74
+ ],
75
+ )
76
+ def test_rejects_hostile_referer(self, malicious: str) -> None:
77
+ req = _make_request(referer=malicious)
78
+ result = safe_referer_or_root(req)
79
+ # The helper must never return anything that, when used as a Location
80
+ # header, takes the browser off-site. The contract is "/" or a path
81
+ # starting with "/" on the same origin.
82
+ assert result.startswith("/"), (
83
+ f"safe_referer_or_root({malicious!r}) returned {result!r}; "
84
+ "must fall back to a same-origin path"
85
+ )
86
+ # And specifically: no second slash that would make the browser see
87
+ # this as a protocol-relative URL.
88
+ assert not result.startswith("//"), (
89
+ f"safe_referer_or_root({malicious!r}) returned protocol-relative {result!r}"
90
+ )
91
+
92
+ def test_userinfo_at_sign_does_not_smuggle_host(self) -> None:
93
+ """``http://evil@testserver/`` parses as host=testserver in urlsplit.
94
+
95
+ That's actually safe — the helper compares parsed.netloc which includes
96
+ the userinfo. The defense is that ``parsed.netloc != current.netloc``
97
+ when userinfo is present, so the comparison correctly rejects.
98
+ """
99
+ req = _make_request(referer="http://attacker@testserver/admin")
100
+ assert safe_referer_or_root(req) == "/"
101
+
102
+
103
+ class TestSafeRefererSchemeAndHostMatching:
104
+ def test_scheme_mismatch_rejects_https_referer_on_http_request(self) -> None:
105
+ req = _make_request(referer="https://testserver/x", scheme="http")
106
+ assert safe_referer_or_root(req) == "/"
107
+
108
+ def test_host_mismatch_rejects_subdomain(self) -> None:
109
+ req = _make_request(referer="http://admin.testserver/x")
110
+ assert safe_referer_or_root(req) == "/"
111
+
112
+ def test_port_mismatch_rejects(self) -> None:
113
+ req = _make_request(referer="http://testserver:8080/x", host="testserver")
114
+ assert safe_referer_or_root(req) == "/"
115
+
116
+
117
+ class TestSafeRefererPathHandling:
118
+ def test_query_string_preserved(self) -> None:
119
+ req = _make_request(referer="http://testserver/x?a=1&b=2")
120
+ assert safe_referer_or_root(req) == "/x?a=1&b=2"
121
+
122
+ def test_fragment_dropped(self) -> None:
123
+ """Fragments aren't sent to the server, so we don't echo them in Location."""
124
+ req = _make_request(referer="http://testserver/x#section")
125
+ assert safe_referer_or_root(req) == "/x"
126
+
127
+ def test_empty_path_becomes_root(self) -> None:
128
+ req = _make_request(referer="http://testserver")
129
+ assert safe_referer_or_root(req) == "/"
@@ -0,0 +1,54 @@
1
+ """SameSite/HttpOnly invariants for the framework's session cookie.
2
+
3
+ CLAUDE.md treats SameSite=Lax as the CSRF defence — there is no explicit
4
+ token middleware. If a future Starlette upgrade silently switched the
5
+ default to ``None`` (used to be the case on older versions), CSRF
6
+ protection would evaporate without any test catching it.
7
+
8
+ The session cookie is also Set-Cookie'd with HttpOnly so a successful XSS
9
+ can't directly exfiltrate the user_id+user_ctx blob.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import httpx
15
+ import pytest
16
+
17
+
18
+ def _set_cookie_for(name: str, response: httpx.Response) -> str:
19
+ """Return the raw Set-Cookie header for ``name`` (or '' if absent).
20
+
21
+ httpx joins multiple Set-Cookie headers with ', ' which makes
22
+ ``response.headers.get`` ambiguous for cookies whose value contains a
23
+ comma. We walk the raw header list instead.
24
+ """
25
+ for header_name, header_value in response.headers.multi_items():
26
+ if header_name.lower() == "set-cookie" and header_value.startswith(f"{name}="):
27
+ return header_value
28
+ return ""
29
+
30
+
31
+ @pytest.mark.anyio
32
+ async def test_session_cookie_is_samesite_lax_and_httponly(client) -> None:
33
+ """Any response that creates the session cookie must mark it Lax + HttpOnly.
34
+
35
+ An unauthenticated request to a protected page (``/dashboard/``)
36
+ deterministically writes to the session: ``AuthMiddleware`` stores the
37
+ intended target in ``session["next"]`` before redirecting to the login
38
+ page. That guarantees ``SessionMiddleware.save()`` emits a Set-Cookie
39
+ header regardless of how other middleware happens to touch the session.
40
+ """
41
+ resp = await client.get("/dashboard/", follow_redirects=False)
42
+ raw = _set_cookie_for("session", resp)
43
+
44
+ assert raw, (
45
+ "Protected route didn't set the session cookie — has SessionMiddleware "
46
+ "or AuthMiddleware been removed from the pipeline?"
47
+ )
48
+ lowered = raw.lower()
49
+ assert "samesite=lax" in lowered, (
50
+ f"Session cookie missing SameSite=Lax — CSRF defence weakened. Raw: {raw!r}"
51
+ )
52
+ assert "httponly" in lowered, (
53
+ f"Session cookie missing HttpOnly — exposes user_id to XSS. Raw: {raw!r}"
54
+ )
@@ -0,0 +1,116 @@
1
+ """``create_app(settings)`` must propagate strict-discovery in non-dev environments.
2
+
3
+ ``app_builder.create_app`` calls ``discover_modules(strict=not settings.is_development)``.
4
+ The existing tests cover ``discover_modules`` directly with strict=True, but
5
+ nothing pins the wiring — a regression that hard-coded ``strict=False`` would
6
+ silently restore the old "drop a broken module and keep booting" behaviour in
7
+ production, which is exactly what CLAUDE.md says must not happen.
8
+
9
+ Reuses the ``_FakeEntryPoint`` and ``_patch_entry_points`` helpers from
10
+ ``framework/core/tests/test_discovery.py`` (the canonical location for the
11
+ entry-point stubbing pattern) rather than redeclaring the same shim here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import importlib.util
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+ from simple_module_core.exceptions import InvalidModuleError
22
+ from simple_module_hosting.app_builder import create_app
23
+ from simple_module_hosting.settings import Settings
24
+
25
+
26
+ def _load_discovery_helpers():
27
+ """Side-load ``framework/core/tests/test_discovery.py`` without mutating ``sys.path``.
28
+
29
+ The core-tests directory isn't a package (no ``__init__.py``), and adding
30
+ it to ``sys.path`` would expose every ``test_*`` module in there as a
31
+ top-level import for the rest of the session — risking name collisions.
32
+ ``spec_from_file_location`` loads just the one file we need into a
33
+ private namespace.
34
+ """
35
+ discovery_path = Path(__file__).resolve().parents[2] / "core" / "tests" / "test_discovery.py"
36
+ spec = importlib.util.spec_from_file_location("_core_test_discovery_helpers", discovery_path)
37
+ assert spec is not None and spec.loader is not None
38
+ mod = importlib.util.module_from_spec(spec)
39
+ spec.loader.exec_module(mod)
40
+ return mod._FakeEntryPoint, mod._boom_loader, mod._patch_entry_points
41
+
42
+
43
+ _FakeEntryPoint, _boom_loader, _patch_entry_points = _load_discovery_helpers()
44
+
45
+
46
+ class _NotAModule:
47
+ """A class returned by an entry point that isn't a ModuleBase subclass."""
48
+
49
+
50
+ def _prod_settings() -> Settings:
51
+ return Settings(
52
+ database_url="sqlite+aiosqlite:///:memory:",
53
+ environment="production",
54
+ secret_key="x" * 32,
55
+ multi_tenant=False,
56
+ )
57
+
58
+
59
+ def test_create_app_in_production_fails_on_broken_entrypoint(monkeypatch):
60
+ """A failed entry-point load in production must abort ``create_app``."""
61
+ _patch_entry_points(monkeypatch, [_FakeEntryPoint("boom", _boom_loader)])
62
+ with pytest.raises(InvalidModuleError, match="Failed to load"):
63
+ create_app(_prod_settings())
64
+
65
+
66
+ def test_create_app_in_production_fails_on_non_modulebase(monkeypatch):
67
+ """Same contract for non-ModuleBase classes registered as entry points."""
68
+ _patch_entry_points(monkeypatch, [_FakeEntryPoint("notmod", _NotAModule)])
69
+ with pytest.raises(InvalidModuleError, match="not a ModuleBase"):
70
+ create_app(_prod_settings())
71
+
72
+
73
+ def test_discover_modules_called_with_strict_mirroring_environment(monkeypatch):
74
+ """The wiring assertion: ``app_builder`` passes ``strict=not is_development``.
75
+
76
+ We don't actually run ``create_app`` in dev — that triggers Inertia setup
77
+ and ``emit_frontend_types``, which mutates the generated i18n type files
78
+ on disk because the entry-point stub yields zero modules. Asserting the
79
+ keyword argument is sufficient to pin the wiring contract.
80
+ """
81
+ captured: dict[str, object] = {}
82
+
83
+ def _spy(*args, **kwargs):
84
+ captured["strict"] = kwargs.get("strict")
85
+ return []
86
+
87
+ monkeypatch.setattr("simple_module_hosting.app_builder.discover_modules", _spy)
88
+ # Block dev-mode side effects (write_module_pages_manifest +
89
+ # emit_frontend_types) — with zero modules they'd rewrite the
90
+ # generated i18n files to empty.
91
+ monkeypatch.setattr(
92
+ "simple_module_hosting.app_builder.emit_frontend_types", lambda *a, **kw: None
93
+ )
94
+ import simple_module_hosting.manifest as manifest_mod
95
+
96
+ monkeypatch.setattr(manifest_mod, "write_module_pages_manifest", lambda *a, **kw: None)
97
+
98
+ # Dev environment — strict must be False.
99
+ dev = Settings(
100
+ database_url="sqlite+aiosqlite:///:memory:",
101
+ environment="development",
102
+ secret_key="x" * 32,
103
+ multi_tenant=False,
104
+ )
105
+ # Builder will fail later (no Inertia templates, no settings module),
106
+ # but we already captured ``strict`` from the spy.
107
+ with contextlib.suppress(Exception):
108
+ create_app(dev)
109
+ assert captured.get("strict") is False, "Dev mode must pass strict=False"
110
+
111
+ # Production environment — strict must be True.
112
+ captured.clear()
113
+ prod = _prod_settings()
114
+ with contextlib.suppress(Exception):
115
+ create_app(prod)
116
+ assert captured.get("strict") is True, "Production mode must pass strict=True"