diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 9f5afb8..699864c 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -25,13 +25,17 @@ jobs: python -m venv .venv .venv/bin/pip install -r requirements-dev.txt - name: Check kaya-core - run: | - .venv/bin/python -m mypy -p kaya.rsgi - .venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests - - name: Check kaya-rsgi run: | .venv/bin/python -m mypy -p kaya.core .venv/bin/python -m unittest discover -s packages/kaya-core/tests + - name: Check kaya-rsgi + run: | + .venv/bin/python -m mypy -p kaya.rsgi + .venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests + - name: Check kaya-session + run: | + .venv/bin/python -m mypy -p kaya.session + .venv/bin/python -m unittest discover -s packages/kaya-session/tests - name: Publish kaya-core artifacts env: TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} @@ -48,3 +52,11 @@ jobs: run: | .venv/bin/pyproject-build packages/kaya-rsgi .venv/bin/twine upload --repository gitea packages/kaya-rsgi/dist/*.whl packages/kaya-rsgi/dist/*.tar.gz + - name: Publish kaya-session artifacts + env: + TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} + TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }} + run: | + .venv/bin/pyproject-build packages/kaya-session + .venv/bin/twine upload --repository gitea packages/kaya-session/dist/*.whl packages/kaya-session/dist/*.tar.gz diff --git a/README.md b/README.md index be16f69..ceb775c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind - **kaya-core** — core routing, HTTP/WS abstractions, and ASGI adapter (`packages/kaya-core/`) - **kaya-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`) +- **kaya-session** — server-side HTTP session management (`packages/kaya-session/`) Additional `kaya-*` packages can be added as new directories under `packages/`. @@ -22,7 +23,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si Install the packages in development mode: ```bash -pip install -e packages/kaya-core -e packages/kaya-rsgi +pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session ``` Run the example: @@ -36,6 +37,7 @@ python example/hello.py ```bash python -m unittest discover -s packages/kaya-core/tests python -m unittest discover -s packages/kaya-rsgi/tests +python -m unittest discover -s packages/kaya-session/tests ``` ## Static analysis @@ -43,6 +45,7 @@ python -m unittest discover -s packages/kaya-rsgi/tests ```bash mypy -p kaya.core mypy -p kaya.rsgi +mypy -p kaya.session ``` ## Building packages @@ -50,4 +53,5 @@ mypy -p kaya.rsgi ```bash python -m build packages/kaya-core python -m build packages/kaya-rsgi +python -m build packages/kaya-session ``` diff --git a/example/session.py b/example/session.py new file mode 100644 index 0000000..bdb47d5 --- /dev/null +++ b/example/session.py @@ -0,0 +1,45 @@ +from kaya.core import HttpContext, KayaApp +from kaya.session import InMemorySessionStore, SessionMiddleware + +app = SessionMiddleware(KayaApp(), InMemorySessionStore()) + + +@app.GET('/') +async def home(ctx: HttpContext) -> None: + visits = ctx.session.get('visits', 0) + 1 + ctx.session['visits'] = visits + await ctx.send_str(200, f'visits: {visits}') + + +@app.GET('/read') +async def read(ctx: HttpContext) -> None: + visits = ctx.session.get('visits', 0) + await ctx.send_str(200, f'visits: {visits}') + + +@app.GET('/clear') +async def clear(ctx: HttpContext) -> None: + ctx.session.invalidate() + await ctx.send_str(200, 'session cleared') + +@app.websocket('/echo') +async def echo(ws: WebSocket) -> None: + await ws.accept() + async for msg in ws: + if msg.kind == 'text': + await ws.send_text(f"echo: {msg.data}") + elif msg.kind == 'binary': + data = msg.data + assert isinstance(data, bytes) + await ws.send_bytes(data) + +@app.websocket('/ws/visits') +async def ws_visits(ws: WebSocket) -> None: + # WebSocket handlers can read the existing session. Most ASGI servers + # (including Granian and Daphne) do not forward the `headers` field of the + # `websocket.accept` message into the HTTP 101 response, so a new session + # cookie cannot be set during the handshake. Use the HTTP `/` endpoint to + # set or refresh the session cookie before connecting here. + await ws.accept() + visits = ws.session.get('visits', 0) + await ws.send_text(f'visits: {visits}') diff --git a/packages/kaya-core/src/kaya/core/_asgi.py b/packages/kaya-core/src/kaya/core/_asgi.py index 5abb495..25f9ba0 100644 --- a/packages/kaya-core/src/kaya/core/_asgi.py +++ b/packages/kaya-core/src/kaya/core/_asgi.py @@ -88,6 +88,7 @@ class AsgiContext(HttpContext): self.server = scope['server'] self.headers = decode_headers(scope['headers']) self.request_body = request_body_iterator + self.session = (scope.get('state') or {}).get('kaya_session') async def stream_body(self, status: int, diff --git a/packages/kaya-core/src/kaya/core/_http_context.py b/packages/kaya-core/src/kaya/core/_http_context.py index 018545b..c76aac3 100644 --- a/packages/kaya-core/src/kaya/core/_http_context.py +++ b/packages/kaya-core/src/kaya/core/_http_context.py @@ -28,6 +28,7 @@ class HttpContext(ABC): client: Optional[Tuple[str, int]] server: Optional[Tuple[str, Optional[int]]] request_body: AsyncIterator[bytes] + session: Optional[Any] = None @abstractmethod async def stream_body(self, diff --git a/packages/kaya-session/README.md b/packages/kaya-session/README.md new file mode 100644 index 0000000..5b587b9 --- /dev/null +++ b/packages/kaya-session/README.md @@ -0,0 +1,42 @@ +# kaya-session + +Session management for the Kaya web framework. + +Provides server-side, identity-agnostic HTTP sessions via a session cookie. The +session data is accessible from request handlers as `ctx.session`. + +## Usage + +```python +from kaya.core import KayaApp, HttpContext +from kaya.session import SessionMiddleware, InMemorySessionStore + +app = KayaApp() +session_app = SessionMiddleware(app, InMemorySessionStore()) + +@session_app.GET('/') +async def home(ctx: HttpContext): + n = ctx.session.get('visits', 0) + 1 + ctx.session['visits'] = n + await ctx.send_str(200, f'visits: {n}') +``` + +Sessions are created lazily: a cookie is only set when the handler modifies the +session. + +## Features + +- `Session`: dict-like session object with modification tracking +- `SessionStore`: abstract store interface +- `InMemorySessionStore`: simple in-memory store for development/single-process +- `SessionMiddleware`: ASGI middleware managing session cookies and persistence +- Session ID regeneration (`session.regenerate_id()`) and invalidation + (`session.invalidate()`) for future authentication layers + +## Notes + +- This release supports HTTP requests only; WebSocket and RSGI propagation is + planned for future releases. +- `InMemorySessionStore` does not survive process restarts and is not shared + across processes. Production deployments should use a store backed by a shared + storage system (planned). diff --git a/packages/kaya-session/pyproject.toml b/packages/kaya-session/pyproject.toml new file mode 100644 index 0000000..68d0745 --- /dev/null +++ b/packages/kaya-session/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "kaya-session" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Session management for the Kaya lightweight ASGI web framework" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'Programming Language :: Python :: 3', +] + +dependencies = [ + "kaya-core", + "pwo", +] + +[project.optional-dependencies] +dev = [ + "build", "mypy", "ipdb", "twine", "httpx", "httpx-ws" +] + +[project.urls] +"Homepage" = "https://github.com/woggioni/kaya" +"Bug Tracker" = "https://github.com/woggioni/kaya/issues" + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true + +[tool.setuptools_scm] +root = "../.." +version_file = "src/kaya/session/_version.py" + +[tool.setuptools_scm.tag] +prefix = "release/" diff --git a/packages/kaya-session/src/kaya/session/__init__.py b/packages/kaya-session/src/kaya/session/__init__.py new file mode 100644 index 0000000..2191e47 --- /dev/null +++ b/packages/kaya-session/src/kaya/session/__init__.py @@ -0,0 +1,11 @@ +from ._middleware import SessionMiddleware +from ._session import Session +from ._store import InMemorySessionStore, SessionStore + + +__all__ = [ + 'InMemorySessionStore', + 'Session', + 'SessionMiddleware', + 'SessionStore', +] diff --git a/packages/kaya-session/src/kaya/session/_cookie.py b/packages/kaya-session/src/kaya/session/_cookie.py new file mode 100644 index 0000000..f9c87de --- /dev/null +++ b/packages/kaya-session/src/kaya/session/_cookie.py @@ -0,0 +1,37 @@ +from http.cookies import SimpleCookie +from typing import Optional + + +def parse_cookie_value(header_value: str, cookie_name: str) -> Optional[str]: + """Return the value of ``cookie_name`` from a ``Cookie`` header, if present.""" + cookie = SimpleCookie() + cookie.load(header_value) + morsel = cookie.get(cookie_name) + if morsel is None: + return None + return morsel.value + + +def format_set_cookie( + name: str, + value: str, + path: str = '/', + max_age: Optional[int] = None, + httponly: bool = True, + secure: bool = False, + samesite: Optional[str] = 'Lax', +) -> str: + """Return a ``Set-Cookie`` value string (without the header name).""" + cookie = SimpleCookie() + cookie[name] = value + morsel = cookie[name] + morsel['path'] = path + if max_age is not None: + morsel['max-age'] = max_age + if httponly: + morsel['httponly'] = True + if secure: + morsel['secure'] = True + if samesite is not None: + morsel['samesite'] = samesite + return morsel.OutputString() diff --git a/packages/kaya-session/src/kaya/session/_middleware.py b/packages/kaya-session/src/kaya/session/_middleware.py new file mode 100644 index 0000000..896d1e3 --- /dev/null +++ b/packages/kaya-session/src/kaya/session/_middleware.py @@ -0,0 +1,178 @@ +from typing import Any, Awaitable, Callable, MutableMapping, Optional, Sequence, cast + +from kaya.core import HttpMethod, KayaApp + +from ._cookie import format_set_cookie, parse_cookie_value +from ._session import Session +from ._store import SessionStore + + +type HttpHandler = Callable[[Any, Any], Awaitable[None]] +type WebSocketHandler = Callable[[Any, Any], Awaitable[None]] +type RouteDecorator = Callable[[HttpHandler], HttpHandler] +type WebSocketDecorator = Callable[[WebSocketHandler], WebSocketHandler] +type ASGIApp = Callable[ + [MutableMapping[str, Any], Callable[[], Awaitable[Any]], Callable[[MutableMapping[str, Any]], Awaitable[None]]], + Awaitable[None], +] + + +class SessionMiddleware: + """ASGI middleware providing server-side HTTP sessions. + + The middleware reads a session cookie from each HTTP request, loads the + session data via the configured store, and makes the session available to + Kaya handlers as ``ctx.session``. After the request it persists the session + and refreshes the cookie. + + Routing methods are delegated to the wrapped ``KayaApp`` so the middleware + can be used as a drop-in replacement when registering handlers. + + Example:: + + app = KayaApp() + session_app = SessionMiddleware(app, InMemorySessionStore()) + + @session_app.GET('/') + async def home(ctx: HttpContext): + ctx.session['visits'] = ctx.session.get('visits', 0) + 1 + await ctx.send_str(200, f"visits: {ctx.session['visits']}") + """ + + def __init__( + self, + app: KayaApp, + store: SessionStore, + cookie_name: str = 'session_id', + path: str = '/', + max_age: Optional[int] = 14 * 24 * 60 * 60, + httponly: bool = True, + secure: bool = False, + samesite: Optional[str] = 'Lax', + ) -> None: + self._app = app + self._store = store + self._cookie_name = cookie_name + self._path = path + self._max_age = max_age + self._httponly = httponly + self._secure = secure + self._samesite = samesite + + def route( + self, + paths: str | Sequence[str], + methods: Optional[HttpMethod | Sequence[HttpMethod]] = None, + recursive: bool = False, + ) -> RouteDecorator: + return self._app.route(paths, methods, recursive) + + def GET(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.GET(path, recursive) + + def POST(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.POST(path, recursive) + + def PUT(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.PUT(path, recursive) + + def DELETE(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.DELETE(path, recursive) + + def OPTIONS(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.OPTIONS(path, recursive) + + def HEAD(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.HEAD(path, recursive) + + def PATCH(self, path: str, recursive: bool = False) -> RouteDecorator: + return self._app.PATCH(path, recursive) + + def websocket(self, path: str, recursive: bool = False) -> WebSocketDecorator: + return self._app.websocket(path, recursive) + + async def __call__( + self, + scope: MutableMapping[str, Any], + receive: Callable[[], Awaitable[Any]], + send: Callable[[MutableMapping[str, Any]], Awaitable[None]], + ) -> None: + if scope['type'] != 'http': + await cast(ASGIApp, self._app)(scope, receive, send) + return + + session_id = self._extract_session_id(scope) + session: Session + if session_id is not None: + loaded = await self._store.load(session_id) + session = loaded if loaded is not None else Session() + else: + session = Session() + + state = scope.get('state') + if not isinstance(state, dict): + state = {} + scope['state'] = state + state['kaya_session'] = session + + async def wrapped_send(message: MutableMapping[str, Any]) -> None: + if message['type'] == 'http.response.start': + message = dict(message) + final_session_id = self._finalize_session_id(session) + if final_session_id is not None: + cookie_value = format_set_cookie( + self._cookie_name, + final_session_id, + path=self._path, + max_age=0 if session.invalidated else self._max_age, + httponly=self._httponly, + secure=self._secure, + samesite=self._samesite, + ) + headers = list(message.get('headers', [])) + headers.append((b'Set-Cookie', cookie_value.encode())) + message['headers'] = headers + await send(message) + + try: + await cast(ASGIApp, self._app)(scope, receive, wrapped_send) + finally: + await self._persist(session) + + def _extract_session_id(self, scope: MutableMapping[str, Any]) -> Optional[str]: + headers = scope.get('headers', []) + for key, value in headers: + key_bytes: bytes = key if isinstance(key, bytes) else key.encode() + if key_bytes.lower() == b'cookie': + value_str: str = value.decode() if isinstance(value, bytes) else value + return parse_cookie_value(value_str, self._cookie_name) + return None + + def _finalize_session_id(self, session: Session) -> Optional[str]: + if session.invalidated: + return session.id + if session.id is None: + if session.modified or session.regenerate: + session.set_id(self._store.new_session_id()) + elif session.regenerate: + session._old_id = session.id + session.set_id(self._store.new_session_id()) + session._regenerate = False + return session.id + + async def _persist(self, session: Session) -> None: + if session.invalidated: + old_id = session._old_id or session.id + if old_id is not None: + await self._store.delete(old_id) + return + + if session._old_id is not None and session._old_id != session.id: + await self._store.delete(session._old_id) + session._old_id = None + + if session.id is None and session.modified: + session.set_id(self._store.new_session_id()) + + if session.id is not None: + await self._store.save(session.id, session) diff --git a/packages/kaya-session/src/kaya/session/_session.py b/packages/kaya-session/src/kaya/session/_session.py new file mode 100644 index 0000000..8633dc9 --- /dev/null +++ b/packages/kaya-session/src/kaya/session/_session.py @@ -0,0 +1,82 @@ +from typing import Any, Iterator, Mapping, MutableMapping, Optional + + +class Session(MutableMapping[str, Any]): + """Dict-like session container with modification tracking. + + The middleware uses the ``modified``, ``regenerate`` and ``invalidated`` + flags to decide whether to persist the session, rotate its ID, or delete + it. + """ + + def __init__(self, session_id: Optional[str] = None, data: Optional[Mapping[str, Any]] = None) -> None: + self._id: Optional[str] = session_id + self._old_id: Optional[str] = None + self._data: dict[str, Any] = dict(data) if data else {} + self._modified: bool = False + self._regenerate: bool = False + self._invalidated: bool = False + + @property + def id(self) -> Optional[str]: + return self._id + + def set_id(self, session_id: str) -> None: + self._id = session_id + + @property + def modified(self) -> bool: + return self._modified + + @property + def regenerate(self) -> bool: + return self._regenerate + + @property + def invalidated(self) -> bool: + return self._invalidated + + def regenerate_id(self) -> None: + """Mark the session for ID rotation. + + This is intended for authentication layers to defend against session + fixation: the middleware will create a new session ID, move the data to + it, and delete the old store entry. + """ + self._old_id = self._id + self._id = None + self._regenerate = True + self._modified = True + + def invalidate(self) -> None: + """Mark the session for deletion. + + The middleware will clear the stored data and send an expired cookie. + """ + self._invalidated = True + self._modified = True + self._data.clear() + + def mark_modified(self) -> None: + self._modified = True + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def __setitem__(self, key: str, value: Any) -> None: + self._data[key] = value + self._modified = True + + def __delitem__(self, key: str) -> None: + del self._data[key] + self._modified = True + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def clear(self) -> None: + self._data.clear() + self._modified = True diff --git a/packages/kaya-session/src/kaya/session/_store.py b/packages/kaya-session/src/kaya/session/_store.py new file mode 100644 index 0000000..31a11fb --- /dev/null +++ b/packages/kaya-session/src/kaya/session/_store.py @@ -0,0 +1,54 @@ +from abc import ABC, abstractmethod +from secrets import token_urlsafe +from typing import Any, Optional + +from ._session import Session + + +class SessionStore(ABC): + """Pluggable backend for session persistence.""" + + @abstractmethod + async def load(self, session_id: str) -> Optional[Session]: + """Load an existing session, or return ``None`` if unknown/expired.""" + pass + + @abstractmethod + async def save(self, session_id: str, session: Session) -> None: + """Persist the session data under ``session_id``.""" + pass + + @abstractmethod + async def delete(self, session_id: str) -> None: + """Remove the session from the store.""" + pass + + def new_session_id(self) -> str: + """Return a new opaque session identifier. + + Subclasses may override this to use a backend-specific ID generator. + """ + return token_urlsafe(32) + + +class InMemorySessionStore(SessionStore): + """Simple in-memory session store. + + Suitable for development and single-process deployments. Session data is + lost when the process exits and is not shared between processes. + """ + + def __init__(self) -> None: + self._sessions: dict[str, dict[str, Any]] = {} + + async def load(self, session_id: str) -> Optional[Session]: + data = self._sessions.get(session_id) + if data is None: + return None + return Session(session_id, data) + + async def save(self, session_id: str, session: Session) -> None: + self._sessions[session_id] = dict(session) + + async def delete(self, session_id: str) -> None: + self._sessions.pop(session_id, None) diff --git a/packages/kaya-session/src/kaya/session/py.typed b/packages/kaya-session/src/kaya/session/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/kaya-session/tests/test_session.py b/packages/kaya-session/tests/test_session.py new file mode 100644 index 0000000..a35e07f --- /dev/null +++ b/packages/kaya-session/tests/test_session.py @@ -0,0 +1,245 @@ +import unittest +from typing import Any + +import httpx +from pwo import async_test +from kaya.core import KayaApp, HttpContext + +from kaya.session import InMemorySessionStore, Session, SessionMiddleware, SessionStore +from kaya.session._cookie import format_set_cookie, parse_cookie_value + + +class SessionTest(unittest.TestCase): + app: KayaApp + store: InMemorySessionStore + session_app: SessionMiddleware + + def setUp(self) -> None: + self.app = KayaApp() + self.store = InMemorySessionStore() + self.session_app = SessionMiddleware(self.app, self.store) + + @self.session_app.GET('/') + async def home(ctx: HttpContext) -> None: + n = ctx.session.get('visits', 0) + 1 + ctx.session['visits'] = n + await ctx.send_str(200, f'visits: {n}') + + @self.session_app.GET('/read') + async def read(ctx: HttpContext) -> None: + n = ctx.session.get('visits', 0) + await ctx.send_str(200, f'visits: {n}') + + @self.session_app.GET('/write') + async def write(ctx: HttpContext) -> None: + ctx.session['foo'] = 'bar' + await ctx.send_str(200, 'ok') + + @self.session_app.GET('/clear') + async def clear(ctx: HttpContext) -> None: + ctx.session.invalidate() + await ctx.send_str(200, 'cleared') + + @self.session_app.GET('/rotate') + async def rotate(ctx: HttpContext) -> None: + ctx.session.regenerate_id() + await ctx.send_str(200, 'rotated') + + @async_test + async def test_session_persists_across_requests(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/') + self.assertEqual(200, r.status_code) + self.assertEqual('visits: 1', r.text) + self.assertIn('Set-Cookie', r.headers) + + r = await client.get('/') + self.assertEqual(200, r.status_code) + self.assertEqual('visits: 2', r.text) + + @async_test + async def test_no_cookie_when_session_not_modified(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/read') + self.assertEqual(200, r.status_code) + self.assertEqual('visits: 0', r.text) + self.assertNotIn('Set-Cookie', r.headers) + + @async_test + async def test_existing_session_refreshes_cookie(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + await client.get('/') + r = await client.get('/read') + self.assertEqual(200, r.status_code) + self.assertIn('Set-Cookie', r.headers) + self.assertEqual('visits: 1', r.text) + + @async_test + async def test_cookie_attributes(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/') + set_cookie = r.headers['Set-Cookie'] + self.assertIn('HttpOnly', set_cookie) + self.assertIn('SameSite=Lax', set_cookie) + self.assertIn('Path=/', set_cookie) + self.assertIn('Max-Age=', set_cookie) + + @async_test + async def test_sessions_are_isolated(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r1 = await client.get('/') + r2 = await client.get('/') + self.assertEqual('visits: 1', r1.text) + self.assertEqual('visits: 2', r2.text) + + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/') + self.assertEqual('visits: 1', r.text) + + @async_test + async def test_invalidate(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + await client.get('/') + r = await client.get('/clear') + self.assertEqual(200, r.status_code) + self.assertEqual('cleared', r.text) + set_cookie = r.headers['Set-Cookie'] + self.assertIn('Max-Age=0', set_cookie) + + r = await client.get('/') + self.assertEqual('visits: 1', r.text) + + @async_test + async def test_regenerate_id(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + await client.get('/') + cookies_before = {c.name: c.value for c in client.cookies.jar} + old_id = cookies_before.get('session_id') + self.assertIsNotNone(old_id) + self.assertIn(old_id, self.store._sessions) + + r = await client.get('/rotate') + self.assertEqual(200, r.status_code) + self.assertEqual('rotated', r.text) + + cookies_after = {c.name: c.value for c in client.cookies.jar} + new_id = cookies_after.get('session_id') + self.assertIsNotNone(new_id) + self.assertNotEqual(old_id, new_id) + self.assertNotIn(old_id, self.store._sessions) + self.assertIn(new_id, self.store._sessions) + + r = await client.get('/read') + self.assertEqual('visits: 1', r.text) + + @async_test + async def test_invalid_cookie_creates_fresh_session(self) -> None: + transport = httpx.ASGITransport(app=self.session_app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + client.cookies.set('session_id', 'not-a-real-id') + r = await client.get('/') + self.assertEqual('visits: 1', r.text) + self.assertIn('Set-Cookie', r.headers) + + +class SessionUnitTest(unittest.TestCase): + + def test_session_is_dict_like(self) -> None: + session = Session() + session['a'] = 1 + self.assertEqual(1, session['a']) + self.assertTrue('a' in session) + self.assertEqual({'a': 1}, dict(session)) + self.assertTrue(session.modified) + + def test_session_modification_tracking(self) -> None: + session = Session(data={'a': 1}) + self.assertFalse(session.modified) + session['a'] = 2 + self.assertTrue(session.modified) + + def test_session_read_does_not_mark_modified(self) -> None: + session = Session(data={'a': 1}) + self.assertFalse(session.modified) + _ = session['a'] + self.assertFalse(session.modified) + + def test_session_clear_marks_modified(self) -> None: + session = Session(data={'a': 1}) + self.assertFalse(session.modified) + session.clear() + self.assertTrue(session.modified) + self.assertEqual(0, len(session)) + + def test_session_invalidate(self) -> None: + session = Session(session_id='abc', data={'a': 1}) + self.assertFalse(session.modified) + session.invalidate() + self.assertTrue(session.invalidated) + self.assertTrue(session.modified) + self.assertEqual(0, len(session)) + + def test_session_regenerate_id(self) -> None: + session = Session(session_id='abc', data={'a': 1}) + session.regenerate_id() + self.assertTrue(session.regenerate) + self.assertIsNone(session.id) + self.assertEqual('abc', session._old_id) + self.assertTrue(session.modified) + + def test_session_store_is_abstract(self) -> None: + with self.assertRaises(TypeError): + SessionStore() # type: ignore[abstract] + + def test_in_memory_store_round_trip(self) -> None: + store = InMemorySessionStore() + session_id = store.new_session_id() + session = Session(session_id, {'a': 1}) + self.assertIsNone(store._sessions.get(session_id)) + + import asyncio + asyncio.run(store.save(session_id, session)) + + loaded = asyncio.run(store.load(session_id)) + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual(1, loaded['a']) + + asyncio.run(store.delete(session_id)) + self.assertIsNone(asyncio.run(store.load(session_id))) + + +class CookieUtilTest(unittest.TestCase): + + def test_parse_cookie_value(self) -> None: + self.assertEqual('bar', parse_cookie_value('foo=bar; baz=qux', 'foo')) + self.assertEqual('qux', parse_cookie_value('foo=bar; baz=qux', 'baz')) + self.assertIsNone(parse_cookie_value('foo=bar', 'missing')) + self.assertIsNone(parse_cookie_value('', 'foo')) + + def test_format_set_cookie(self) -> None: + value = format_set_cookie('sid', 'abc123', path='/', max_age=3600, httponly=True, secure=True, samesite='Strict') + self.assertIn('sid=abc123', value) + self.assertIn('Path=/', value) + self.assertIn('Max-Age=3600', value) + self.assertIn('HttpOnly', value) + self.assertIn('Secure', value) + self.assertIn('SameSite=Strict', value) + + def test_format_set_cookie_without_secure(self) -> None: + value = format_set_cookie('sid', 'abc123', max_age=3600) + self.assertIn('sid=abc123', value) + self.assertIn('HttpOnly', value) + self.assertNotIn('Secure', value) + self.assertIn('SameSite=Lax', value) + + +if __name__ == '__main__': + unittest.main() diff --git a/requirements-dev.in b/requirements-dev.in index 84b01f1..0b0d013 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -1,5 +1,6 @@ kaya-core @ file:./packages/kaya-core kaya-rsgi @ file:./packages/kaya-rsgi +kaya-session @ file:./packages/kaya-session build mypy ipdb diff --git a/requirements-dev.txt b/requirements-dev.txt index 31596a2..69c4f48 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -83,8 +83,11 @@ file:./packages/kaya-core # via # -r requirements-dev.in # kaya-rsgi + # kaya-session file:./packages/kaya-rsgi # via -r requirements-dev.in +file:./packages/kaya-session + # via -r requirements-dev.in keyring==25.7.0 # via twine librt==0.13.0