kernia-django 0.1.0__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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ .uv/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ dist/
13
+ build/
14
+
15
+ # Editors
16
+ .idea/
17
+ .vscode/
18
+ *.swp
19
+ .DS_Store
20
+
21
+ # Docs build output
22
+ /site/
23
+ docs/site/
24
+
25
+ # Internal tooling (not part of the public repo)
26
+ scripts/audit_layout.py
27
+ scripts/setup_kernia_dns.sh
28
+ spec/
29
+
30
+ # Local
31
+
32
+ .projects/cache
33
+ .projects/vault
34
+ .projects/state.test.json
35
+ .projects/state.local.test.json
36
+ .env
37
+ .env.local
38
+ .env.test
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Advantch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: kernia-django
3
+ Version: 0.1.0
4
+ Summary: Django integration
5
+ Project-URL: Homepage, https://kernia.dev
6
+ Project-URL: Documentation, https://kernia.dev/docs
7
+ Project-URL: Source, https://github.com/advantch/kernia
8
+ Project-URL: Issues, https://github.com/advantch/kernia/issues
9
+ Project-URL: Changelog, https://github.com/advantch/kernia/releases
10
+ Author: Advantch
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: asgi,authentication,authorization,django,fastapi,oauth,passkeys,security,sessions,sso,starlette
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: Session
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: anyio>=4
26
+ Requires-Dist: django>=4.2
27
+ Requires-Dist: kernia>=0.1.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # kernia-django
31
+
32
+ Django integration for Kernia. Mounts the auth router, populates `request.kernia_session` and `request.kernia_user`, and provides a view decorator for protected views.
33
+
34
+ Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
35
+
36
+ ## Installation
37
+
38
+ pip install kernia-django
39
+
40
+ ## Usage
41
+
42
+ Add the app to `INSTALLED_APPS` and the middleware, then splice the auth routes into `urls.py`:
43
+
44
+ ```python
45
+ # urls.py
46
+ from django.urls import path
47
+ from kernia_django import setup
48
+ from myproject.auth import auth # your init(KerniaOptions(...)) instance
49
+
50
+ urlpatterns = [
51
+ *setup(auth, url_prefix="/api/auth"),
52
+ ]
53
+ ```
54
+
55
+ ```python
56
+ # views.py
57
+ from kernia_django import require_session
58
+
59
+ @require_session
60
+ def me(request):
61
+ return JsonResponse({"user_id": request.kernia_user["id"]})
62
+ ```
63
+
64
+ Django is sync-by-default; the bridge uses `asgiref.sync.async_to_sync` to call the async core.
65
+
66
+ ## Documentation
67
+
68
+ Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,43 @@
1
+ # kernia-django
2
+
3
+ Django integration for Kernia. Mounts the auth router, populates `request.kernia_session` and `request.kernia_user`, and provides a view decorator for protected views.
4
+
5
+ Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
6
+
7
+ ## Installation
8
+
9
+ pip install kernia-django
10
+
11
+ ## Usage
12
+
13
+ Add the app to `INSTALLED_APPS` and the middleware, then splice the auth routes into `urls.py`:
14
+
15
+ ```python
16
+ # urls.py
17
+ from django.urls import path
18
+ from kernia_django import setup
19
+ from myproject.auth import auth # your init(KerniaOptions(...)) instance
20
+
21
+ urlpatterns = [
22
+ *setup(auth, url_prefix="/api/auth"),
23
+ ]
24
+ ```
25
+
26
+ ```python
27
+ # views.py
28
+ from kernia_django import require_session
29
+
30
+ @require_session
31
+ def me(request):
32
+ return JsonResponse({"user_id": request.kernia_user["id"]})
33
+ ```
34
+
35
+ Django is sync-by-default; the bridge uses `asgiref.sync.async_to_sync` to call the async core.
36
+
37
+ ## Documentation
38
+
39
+ Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
40
+
41
+ ## License
42
+
43
+ MIT
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "kernia-django"
3
+ version = "0.1.0"
4
+ description = "Django integration"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "kernia>=0.1.0",
8
+ "django>=4.2",
9
+ "anyio>=4",
10
+ ]
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ license-files = [
14
+ "LICENSE",
15
+ ]
16
+ authors = [
17
+ {name = "Advantch"},
18
+ ]
19
+ keywords = [
20
+ "authentication",
21
+ "authorization",
22
+ "sessions",
23
+ "oauth",
24
+ "passkeys",
25
+ "sso",
26
+ "asgi",
27
+ "fastapi",
28
+ "starlette",
29
+ "django",
30
+ "security",
31
+ ]
32
+ classifiers = [
33
+ "Development Status :: 4 - Beta",
34
+ "Intended Audience :: Developers",
35
+ "Operating System :: OS Independent",
36
+ "Programming Language :: Python :: 3",
37
+ "Programming Language :: Python :: 3.11",
38
+ "Programming Language :: Python :: 3.12",
39
+ "Topic :: Software Development :: Libraries :: Python Modules",
40
+ "Topic :: Internet :: WWW/HTTP :: Session",
41
+ "Topic :: Security",
42
+ "Typing :: Typed",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://kernia.dev"
47
+ Documentation = "https://kernia.dev/docs"
48
+ Source = "https://github.com/advantch/kernia"
49
+ Issues = "https://github.com/advantch/kernia/issues"
50
+ Changelog = "https://github.com/advantch/kernia/releases"
51
+ [build-system]
52
+ requires = ["hatchling"]
53
+ build-backend = "hatchling.build"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/kernia_django"]
@@ -0,0 +1,29 @@
1
+ """Django integration for Kernia.
2
+
3
+ Public surface:
4
+
5
+ * :class:`apps.KerniaConfig` — Django app config (add to ``INSTALLED_APPS``).
6
+ * :class:`middleware.KerniaMiddleware` — populates
7
+ ``request.kernia_session`` / ``request.kernia_user``.
8
+ * :func:`decorators.require_session` — 401-JSON view decorator.
9
+ * :class:`views.KerniaView` — class-based view that forwards to the
10
+ Kernia ASGI router.
11
+ * :func:`setup` — convenience that returns urlpatterns for the auth router.
12
+
13
+ Django is sync-by-default; the bridge uses ``asgiref.sync.async_to_sync`` to
14
+ call the async core. Each Kernia request pays one thread hop.
15
+ """
16
+
17
+ from kernia_django.decorators import require_session
18
+ from kernia_django.middleware import KerniaMiddleware
19
+ from kernia_django.views import KerniaView, setup
20
+
21
+ default_app_config = "kernia_django.apps.KerniaConfig"
22
+
23
+ __all__ = [
24
+ "KerniaMiddleware",
25
+ "KerniaView",
26
+ "default_app_config",
27
+ "require_session",
28
+ "setup",
29
+ ]
@@ -0,0 +1,24 @@
1
+ """Django app config for kernia-django.
2
+
3
+ Importing this module triggers no Django machinery; the config is only loaded
4
+ once the user adds ``kernia_django`` to ``INSTALLED_APPS``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from django.apps import AppConfig
10
+
11
+
12
+ class KerniaConfig(AppConfig):
13
+ """Standard Django app config — no ready-hook side effects.
14
+
15
+ The auth instance lives on whatever the user passes into ``setup()`` /
16
+ ``KerniaView.as_view(auth=...)``. We deliberately don't read it from
17
+ settings here so users can construct the ``Kernia`` object at module
18
+ load time without forcing a Django settings dependency on the core.
19
+ """
20
+
21
+ name = "kernia_django"
22
+ label = "kernia_django"
23
+ verbose_name = "Better Auth"
24
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,27 @@
1
+ """View decorators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from functools import wraps
7
+ from typing import Any
8
+
9
+ from django.http import HttpRequest, JsonResponse
10
+
11
+
12
+ def require_session(view_func: Callable[..., Any]) -> Callable[..., Any]:
13
+ """Return 401 JSON if ``request.kernia_session`` is not set.
14
+
15
+ Relies on :class:`KerniaMiddleware` having already populated the
16
+ attribute. Without the middleware in ``MIDDLEWARE`` the attribute will not
17
+ exist; we treat that as unauthenticated as well (fail-closed).
18
+ """
19
+
20
+ @wraps(view_func)
21
+ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
22
+ session = getattr(request, "kernia_session", None)
23
+ if session is None:
24
+ return JsonResponse({"error": "UNAUTHORIZED"}, status=401)
25
+ return view_func(request, *args, **kwargs)
26
+
27
+ return wrapper
@@ -0,0 +1,83 @@
1
+ """Django middleware that hydrates ``request.kernia_session``.
2
+
3
+ The middleware reads the session cookie, resolves it through the Kernia
4
+ core, and attaches the result (which may be ``None``) under a dedicated
5
+ namespace so Django's own auth/session stack remains untouched.
6
+
7
+ Because Django middleware runs sync by default, we cross the async boundary
8
+ with :func:`asgiref.sync.async_to_sync`. That is a thread hop per request —
9
+ the same trade-off documented on :class:`KerniaView`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from asgiref.sync import async_to_sync
17
+ from django.http import HttpRequest
18
+ from django.utils.deprecation import MiddlewareMixin
19
+ from kernia.auth import Kernia
20
+ from kernia.integrations.session import (
21
+ SESSION_COOKIE_NAME,
22
+ resolve_session,
23
+ )
24
+
25
+
26
+ class KerniaMiddleware(MiddlewareMixin):
27
+ """Attach ``request.kernia_session`` and ``request.kernia_user``.
28
+
29
+ The auth instance is sourced from ``settings.KERNIA`` (the user's
30
+ own ``Kernia`` object). Falling back to a no-op if the setting isn't
31
+ configured keeps the middleware importable from tests that don't wire it.
32
+ """
33
+
34
+ def process_request(self, request: HttpRequest) -> None:
35
+ auth = self._auth_from_settings()
36
+ request.kernia_session = None # type: ignore[attr-defined]
37
+ request.kernia_user = None # type: ignore[attr-defined]
38
+ if auth is None:
39
+ return
40
+ cookie = request.COOKIES.get(SESSION_COOKIE_NAME)
41
+ if not cookie:
42
+ return
43
+ session = async_to_sync(resolve_session)(auth, cookie)
44
+ if session is None:
45
+ return
46
+ request.kernia_session = session # type: ignore[attr-defined]
47
+ # Lazily resolve the user only when asked; many requests just need the
48
+ # session id. We expose a thin callable proxy via attribute access.
49
+ request.kernia_user = _UserAccessor(auth, session.user_id) # type: ignore[attr-defined]
50
+
51
+ @staticmethod
52
+ def _auth_from_settings() -> Kernia | None:
53
+ from django.conf import settings
54
+
55
+ return getattr(settings, "KERNIA", None)
56
+
57
+
58
+ class _UserAccessor:
59
+ """Lazy user lookup.
60
+
61
+ ``request.kernia_user`` is a callable that loads the user row on first
62
+ access; we keep it lazy so the middleware only pays for the session lookup
63
+ unless the view actually wants the user.
64
+ """
65
+
66
+ def __init__(self, auth: Kernia, user_id: str) -> None:
67
+ self._auth = auth
68
+ self._user_id = user_id
69
+ self._cached: dict[str, Any] | None = None
70
+
71
+ @property
72
+ def id(self) -> str:
73
+ return self._user_id
74
+
75
+ def load(self) -> dict[str, Any] | None:
76
+ if self._cached is None:
77
+ from kernia.types.adapter import Where
78
+
79
+ self._cached = async_to_sync(self._auth.context.adapter.find_one)(
80
+ model="user",
81
+ where=(Where(field="id", value=self._user_id),),
82
+ )
83
+ return self._cached
File without changes
@@ -0,0 +1,211 @@
1
+ """Django view that bridges to the Kernia ASGI router.
2
+
3
+ Approach: Django's request/response objects are a different shape from ASGI, so
4
+ the bridge converts a Django ``HttpRequest`` into an ASGI ``scope`` + receive
5
+ queue, drives the inner router, and reassembles the response body / headers
6
+ into a Django ``HttpResponse``.
7
+
8
+ The bridge is sync at the Django edge (``View.dispatch`` is sync by default)
9
+ and async on the inside. We use ``asgiref.sync.async_to_sync`` to cross the
10
+ boundary. Every Kernia request therefore costs one thread hop. This is a
11
+ known and intentional tradeoff: Django remains sync-friendly without forcing
12
+ ASGI workers on the user.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from http.cookies import SimpleCookie
18
+ from typing import Any, ClassVar
19
+
20
+ from asgiref.sync import async_to_sync
21
+ from django.http import HttpRequest, HttpResponse
22
+ from django.urls import path
23
+ from django.views import View
24
+ from kernia.auth import Kernia
25
+ from kernia.integrations.session import strip_base_path
26
+
27
+
28
+ def django_request_to_scope(
29
+ request: HttpRequest,
30
+ *,
31
+ base_path: str = "",
32
+ ) -> dict[str, Any]:
33
+ """Translate a Django HttpRequest into an ASGI HTTP scope.
34
+
35
+ ``base_path`` is the mount prefix the auth router was registered under; we
36
+ strip it from ``path`` so the inner router sees canonical relative paths
37
+ (e.g. ``/sign-in/email``). Set to ``""`` to leave the path alone.
38
+ """
39
+ full_path = request.path
40
+ # Build a minimal ASGI HTTP scope. We don't try to be exhaustive — only the
41
+ # fields Kernia actually reads (path, method, headers, query_string,
42
+ # raw_path, scheme, client).
43
+ headers: list[tuple[bytes, bytes]] = []
44
+ for key, value in request.META.items():
45
+ if key.startswith("HTTP_"):
46
+ name = key[5:].replace("_", "-").lower().encode("latin-1")
47
+ headers.append((name, str(value).encode("latin-1")))
48
+ elif key in ("CONTENT_TYPE", "CONTENT_LENGTH") and value:
49
+ name = key.replace("_", "-").lower().encode("latin-1")
50
+ headers.append((name, str(value).encode("latin-1")))
51
+
52
+ # Django's request.GET is already parsed; the router doesn't need it as a
53
+ # mapping, but does inspect raw query_string from scope.
54
+ query_string = request.META.get("QUERY_STRING", "")
55
+ if isinstance(query_string, str):
56
+ query_string = query_string.encode("latin-1")
57
+
58
+ scope: dict[str, Any] = {
59
+ "type": "http",
60
+ "asgi": {"version": "3.0", "spec_version": "2.3"},
61
+ "http_version": "1.1",
62
+ "method": request.method or "GET",
63
+ "scheme": request.scheme or "http",
64
+ "path": full_path,
65
+ "raw_path": full_path.encode("latin-1"),
66
+ "query_string": query_string,
67
+ "root_path": "",
68
+ "headers": headers,
69
+ "server": (request.get_host().split(":")[0], request.get_port() or 80),
70
+ "client": (request.META.get("REMOTE_ADDR", "") or "", 0),
71
+ }
72
+ if base_path:
73
+ scope = strip_base_path(scope, base_path)
74
+ return scope
75
+
76
+
77
+ async def _drive(
78
+ inner: Any,
79
+ scope: dict[str, Any],
80
+ body: bytes,
81
+ ) -> tuple[int, list[tuple[bytes, bytes]], bytes]:
82
+ """Drive a single HTTP exchange against an ASGI app, return the response."""
83
+ sent_body = bytearray()
84
+ status: int = 500
85
+ headers: list[tuple[bytes, bytes]] = []
86
+
87
+ request_sent = False
88
+
89
+ async def receive() -> dict[str, Any]:
90
+ nonlocal request_sent
91
+ if not request_sent:
92
+ request_sent = True
93
+ return {"type": "http.request", "body": body, "more_body": False}
94
+ # If the app asks for more we report disconnect to avoid hanging.
95
+ return {"type": "http.disconnect"}
96
+
97
+ async def send(message: dict[str, Any]) -> None:
98
+ nonlocal status, headers
99
+ if message["type"] == "http.response.start":
100
+ status = int(message["status"])
101
+ headers = list(message.get("headers", []))
102
+ elif message["type"] == "http.response.body":
103
+ chunk = message.get("body", b"") or b""
104
+ sent_body.extend(chunk)
105
+
106
+ await inner(scope, receive, send)
107
+ return status, headers, bytes(sent_body)
108
+
109
+
110
+ class KerniaView(View):
111
+ """Class-based view that funnels every method onto the auth router.
112
+
113
+ Usage::
114
+
115
+ KerniaView.as_view(auth=my_auth, base_path="/api/auth")
116
+ """
117
+
118
+ auth: Kernia | None = None
119
+ base_path: str = "/api/auth"
120
+
121
+ http_method_names: ClassVar[list[str]] = [
122
+ "get",
123
+ "post",
124
+ "put",
125
+ "patch",
126
+ "delete",
127
+ "head",
128
+ "options",
129
+ ]
130
+
131
+ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
132
+ if self.auth is None:
133
+ raise RuntimeError("KerniaView requires an `auth` kwarg passed via .as_view()")
134
+ # ``rest`` is captured by the URL pattern; rebuild the full path because
135
+ # the inner router decides routing from scope["path"].
136
+ rest = kwargs.get("rest", "")
137
+ full_path = self.base_path.rstrip("/") + "/" + rest if rest else self.base_path
138
+ # Rewrite the Django request's path so the scope mirrors what would
139
+ # have been hit if Django were ASGI-native.
140
+ request.path = full_path
141
+ request.path_info = full_path
142
+
143
+ inner = self.auth.router.mount()
144
+ scope = django_request_to_scope(request, base_path=self.base_path.rstrip("/"))
145
+ body = request.body or b""
146
+
147
+ status, headers, response_body = async_to_sync(_drive)(inner, scope, body)
148
+
149
+ # Build Django response. Pull Content-Type for HttpResponse's ctor;
150
+ # parse Set-Cookie lines into response.cookies so Django emits each
151
+ # cookie on its own header (joining Set-Cookie with commas is broken
152
+ # for cookies whose attributes contain commas, e.g. ``Expires``).
153
+ content_type = None
154
+ cookie_lines: list[str] = []
155
+ remaining: list[tuple[bytes, bytes]] = []
156
+ for k, v in headers:
157
+ name = k.decode("latin-1")
158
+ value = v.decode("latin-1")
159
+ if name.lower() == "content-type" and content_type is None:
160
+ content_type = value
161
+ elif name.lower() == "set-cookie":
162
+ cookie_lines.append(value)
163
+ else:
164
+ remaining.append((k, v))
165
+
166
+ resp = HttpResponse(
167
+ response_body,
168
+ status=status,
169
+ content_type=content_type,
170
+ )
171
+ for k, v in remaining:
172
+ resp.headers[k.decode("latin-1")] = v.decode("latin-1")
173
+ for line in cookie_lines:
174
+ jar: SimpleCookie = SimpleCookie()
175
+ jar.load(line)
176
+ for morsel in jar.values():
177
+ resp.cookies[morsel.key] = morsel.value
178
+ for attr in (
179
+ "expires",
180
+ "path",
181
+ "domain",
182
+ "secure",
183
+ "httponly",
184
+ "samesite",
185
+ "max-age",
186
+ ):
187
+ if morsel[attr]:
188
+ resp.cookies[morsel.key][attr] = morsel[attr]
189
+ return resp
190
+
191
+
192
+ def setup(
193
+ auth: Kernia,
194
+ url_prefix: str = "/api/auth",
195
+ ) -> list[Any]:
196
+ """Return a list of urlpatterns ready to splice into ``urls.py``.
197
+
198
+ The patterns use a catch-all ``<path:rest>`` so every sub-route under the
199
+ prefix lands on ``KerniaView``.
200
+ """
201
+ prefix = url_prefix.strip("/")
202
+ return [
203
+ path(
204
+ f"{prefix}/<path:rest>",
205
+ KerniaView.as_view(auth=auth, base_path="/" + prefix),
206
+ ),
207
+ path(
208
+ f"{prefix}",
209
+ KerniaView.as_view(auth=auth, base_path="/" + prefix),
210
+ ),
211
+ ]
@@ -0,0 +1,88 @@
1
+ """Unit tests for the Django request → ASGI scope translator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ pytest.importorskip("django")
8
+
9
+ import django
10
+ from django.conf import settings
11
+
12
+
13
+ def _ensure_django() -> None:
14
+ if not settings.configured:
15
+ settings.configure(
16
+ DEBUG=True,
17
+ SECRET_KEY="test-secret",
18
+ DATABASES={
19
+ "default": {
20
+ "ENGINE": "django.db.backends.sqlite3",
21
+ "NAME": ":memory:",
22
+ }
23
+ },
24
+ INSTALLED_APPS=["kernia_django"],
25
+ MIDDLEWARE=[],
26
+ ROOT_URLCONF=None,
27
+ ALLOWED_HOSTS=["*"],
28
+ USE_TZ=True,
29
+ )
30
+ django.setup()
31
+
32
+
33
+ _ensure_django()
34
+
35
+ from django.test import RequestFactory # noqa: E402
36
+ from kernia_django.views import django_request_to_scope # noqa: E402
37
+
38
+
39
+ def test_translator_basic_get() -> None:
40
+ rf = RequestFactory()
41
+ req = rf.get(
42
+ "/api/auth/get-session?foo=bar",
43
+ HTTP_COOKIE="better-auth.session_token=abc",
44
+ HTTP_X_CUSTOM="yes",
45
+ )
46
+ scope = django_request_to_scope(req)
47
+ assert scope["type"] == "http"
48
+ assert scope["method"] == "GET"
49
+ assert scope["path"] == "/api/auth/get-session"
50
+ assert scope["query_string"] == b"foo=bar"
51
+ headers = dict(scope["headers"])
52
+ assert headers[b"cookie"] == b"better-auth.session_token=abc"
53
+ assert headers[b"x-custom"] == b"yes"
54
+
55
+
56
+ def test_translator_strips_base_path() -> None:
57
+ rf = RequestFactory()
58
+ req = rf.get("/api/auth/sign-in/email")
59
+ scope = django_request_to_scope(req, base_path="/api/auth")
60
+ assert scope["path"] == "/sign-in/email"
61
+
62
+
63
+ def test_translator_post_includes_content_type() -> None:
64
+ rf = RequestFactory()
65
+ req = rf.post(
66
+ "/api/auth/sign-up/email",
67
+ data='{"email":"x@y.com"}',
68
+ content_type="application/json",
69
+ )
70
+ scope = django_request_to_scope(req, base_path="/api/auth")
71
+ assert scope["method"] == "POST"
72
+ assert scope["path"] == "/sign-up/email"
73
+ headers = dict(scope["headers"])
74
+ assert headers[b"content-type"] == b"application/json"
75
+
76
+
77
+ def test_translator_root_path_becomes_slash() -> None:
78
+ rf = RequestFactory()
79
+ req = rf.get("/api/auth")
80
+ scope = django_request_to_scope(req, base_path="/api/auth")
81
+ assert scope["path"] == "/"
82
+
83
+
84
+ def test_translator_non_matching_base_path_unchanged() -> None:
85
+ rf = RequestFactory()
86
+ req = rf.get("/elsewhere")
87
+ scope = django_request_to_scope(req, base_path="/api/auth")
88
+ assert scope["path"] == "/elsewhere"