simple-module-hosting 0.0.13__tar.gz → 0.0.15__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.
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/pyproject.toml +3 -3
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_observability.py +12 -4
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/logging.py +3 -0
- simple_module_hosting-0.0.15/tests/test_check_migrations.py +84 -0
- simple_module_hosting-0.0.15/tests/test_lifespan_order.py +85 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_logging.py +31 -0
- simple_module_hosting-0.0.15/tests/test_middleware_order.py +79 -0
- simple_module_hosting-0.0.15/tests/test_redirects.py +129 -0
- simple_module_hosting-0.0.15/tests/test_session_cookie_security.py +54 -0
- simple_module_hosting-0.0.15/tests/test_strict_discovery_wiring.py +116 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/.gitignore +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/LICENSE +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/README.md +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_error_handlers.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_inertia_setup.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_phase_helpers.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/app_builder.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/middleware.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/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
|
+
Version: 0.0.15
|
|
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.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.15
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.15
|
|
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
|
+
version = "0.0.15"
|
|
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.
|
|
30
|
-
"simple_module_db==0.0.
|
|
29
|
+
"simple_module_core==0.0.15",
|
|
30
|
+
"simple_module_db==0.0.15",
|
|
31
31
|
"starlette>=0.44",
|
|
32
32
|
"tomlkit>=0.13",
|
|
33
33
|
"uvicorn[standard]>=0.34",
|
|
@@ -26,10 +26,16 @@ _QUIET_PREFIXES = ("/health", "/static/")
|
|
|
26
26
|
class CorrelationIdMiddleware:
|
|
27
27
|
"""Generate or propagate a correlation ID for every request.
|
|
28
28
|
|
|
29
|
-
Reads the incoming ``X-Correlation-ID`` header (or generates a UUID4) and
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
Reads the incoming ``X-Correlation-ID`` header (or generates a UUID4) and:
|
|
30
|
+
|
|
31
|
+
* stores it in the ``simple_module_hosting.logging.correlation_id``
|
|
32
|
+
ContextVar so the stdlib logging filter (or a user-supplied structlog
|
|
33
|
+
processor — see ``docs/framework/middleware.md``) picks it up with no
|
|
34
|
+
per-handler plumbing;
|
|
35
|
+
* exposes it on ``request.state.correlation_id`` for handlers that
|
|
36
|
+
prefer the request object over the contextvar;
|
|
37
|
+
* echoes the value back as the ``X-Correlation-ID`` response header so
|
|
38
|
+
clients can cross-reference their request with server-side logs.
|
|
33
39
|
"""
|
|
34
40
|
|
|
35
41
|
HEADER = "X-Correlation-ID"
|
|
@@ -43,6 +49,8 @@ class CorrelationIdMiddleware:
|
|
|
43
49
|
return
|
|
44
50
|
|
|
45
51
|
cid = Headers(scope=scope).get(self.HEADER) or uuid.uuid4().hex
|
|
52
|
+
# Skip allocating a Request wrapper — downstream Request(scope).state reads this same dict.
|
|
53
|
+
scope.setdefault("state", {})["correlation_id"] = cid
|
|
46
54
|
|
|
47
55
|
async def send_with_header(message: Message) -> None:
|
|
48
56
|
if message["type"] == _MSG_RESPONSE_START:
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
from simple_module_hosting.logging import (
|
|
@@ -12,6 +13,13 @@ from simple_module_hosting.logging import (
|
|
|
12
13
|
correlation_id,
|
|
13
14
|
setup_logging,
|
|
14
15
|
)
|
|
16
|
+
from simple_module_hosting.middleware import CorrelationIdMiddleware
|
|
17
|
+
from starlette.applications import Starlette
|
|
18
|
+
from starlette.responses import JSONResponse
|
|
19
|
+
from starlette.routing import Route
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from starlette.requests import Request
|
|
15
23
|
|
|
16
24
|
# ── JsonFormatter ──────────────────────────────────────────────────────
|
|
17
25
|
|
|
@@ -174,6 +182,29 @@ class TestCorrelationIdMiddleware:
|
|
|
174
182
|
r2 = await client.get("/health")
|
|
175
183
|
assert r1.headers["x-correlation-id"] != r2.headers["x-correlation-id"]
|
|
176
184
|
|
|
185
|
+
async def test_state_contextvar_and_header_agree(self):
|
|
186
|
+
# Background tasks read the ContextVar; handlers read request.state;
|
|
187
|
+
# clients read the response header — all three must agree per request.
|
|
188
|
+
async def echo(request: Request) -> JSONResponse:
|
|
189
|
+
return JSONResponse(
|
|
190
|
+
{
|
|
191
|
+
"state": request.state.correlation_id,
|
|
192
|
+
"contextvar": correlation_id.get(""),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
app = Starlette(routes=[Route("/echo", echo)])
|
|
197
|
+
app.add_middleware(CorrelationIdMiddleware)
|
|
198
|
+
|
|
199
|
+
transport = httpx.ASGITransport(app=app)
|
|
200
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c:
|
|
201
|
+
resp = await c.get("/echo", headers={CorrelationIdMiddleware.HEADER: "trace-xyz"})
|
|
202
|
+
|
|
203
|
+
assert resp.headers[CorrelationIdMiddleware.HEADER] == "trace-xyz"
|
|
204
|
+
body = resp.json()
|
|
205
|
+
assert body["state"] == "trace-xyz"
|
|
206
|
+
assert body["contextvar"] == "trace-xyz"
|
|
207
|
+
|
|
177
208
|
|
|
178
209
|
# ── Request logging middleware (integration) ────────────────────────────
|
|
179
210
|
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/__init__.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/_hydrate_step.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/app_builder.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/health.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/host_cli.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/host_settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/i18n_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/i18n_manifest.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/inertia_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/inertia_utils.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/middleware.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/migrations.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/permissions.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/py.typed
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/redirects.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/simple_module_hosting/settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_hosting_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_locale_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_settings_secrets.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.13 → simple_module_hosting-0.0.15}/tests/test_tenant_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|