From 4c375fc4c191e62432fab345f6f9dca2c33a150f Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Wed, 12 Feb 2025 14:51:12 +0000 Subject: [PATCH] Prepare release (#142) * Fix Session middleware duplicate * Add tests --- docs/en/docs/release-notes.md | 4 ++ lilya/__init__.py | 2 +- lilya/middleware/sessions.py | 91 +++++++++-------------------- pyproject.toml | 2 +- tests/test_responses.py | 106 ++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 66 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 1f0d177..51bb3ef 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -15,6 +15,10 @@ hide: - Declaring `DefinePermission` became optional as Lilya automatically wraps if not provided. - Declaring `DefineMiddleware` became optional as Lilya automatically wraps if not provided. +- +### Fixed + +- `SessionMiddleware` was creating duplicates because it was called on every lifecycle. ## 0.12.6 diff --git a/lilya/__init__.py b/lilya/__init__.py index 8e2394f..6ece8ad 100644 --- a/lilya/__init__.py +++ b/lilya/__init__.py @@ -1 +1 @@ -__version__ = "0.12.6" +__version__ = "0.12.7" diff --git a/lilya/middleware/sessions.py b/lilya/middleware/sessions.py index be2f6f5..8173b63 100644 --- a/lilya/middleware/sessions.py +++ b/lilya/middleware/sessions.py @@ -59,11 +59,6 @@ def __init__( async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: """ ASGI application callable. - - Args: - scope (Scope): ASGI scope. - receive (Receive): ASGI receive channel. - send (Send): ASGI send channel. """ if scope["type"] not in ("http", "websocket"): await self.app(scope, receive, send) @@ -89,13 +84,6 @@ def encode_session(self, session: Any) -> bytes: async def load_session_data(self, scope: Scope, connection: Connection) -> bool: """ Load session data from the session cookie. - - Args: - scope (Scope): ASGI scope. - connection (Connection): HTTP connection object. - - Returns: - bool: True if the initial session was empty, False otherwise. """ if self.session_cookie in connection.cookies: data = connection.cookies[self.session_cookie].encode("utf-8") @@ -117,59 +105,34 @@ async def process_response( send: Send, ) -> None: """ - Process the response and set the session cookie. - - Args: - message (Message): ASGI message. - scope (Scope): ASGI scope. - initial_session_was_empty (bool): True if the initial session was empty, False otherwise. - send (Send): ASGI send channel. + Process the response and set the session cookie. Handles multiple messages. """ if message["type"] == "http.response.start": - if scope["session"]: - message = await self.set_session_cookie(scope, message) - elif not initial_session_was_empty: - message = await self.clear_session_cookie(scope, message) + headers = Header.ensure_header_instance(scope=message) + if "set-cookie" not in (h.lower() for h in headers.keys()): # Check if already set + if scope["session"]: + data = self.encode_session(scope["session"]) + data = self.signer.sign(data) + header_value = ( + "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( + session_cookie=self.session_cookie, + data=data.decode("utf-8"), + path=self.path, + max_age=f"Max-Age={self.max_age}; " if self.max_age else "", + security_flags=self.security_flags, + ) + ) + headers.add("Set-Cookie", header_value) + elif not initial_session_was_empty: + header_value = ( + "{session_cookie}={data}; path={path}; {expires}{security_flags}".format( + session_cookie=self.session_cookie, + data="null", + path=self.path, + expires="expires=Thu, 01 Jan 1970 00:00:00 GMT; ", + security_flags=self.security_flags, + ) + ) + headers.add("Set-Cookie", header_value) await send(message) - - async def set_session_cookie(self, scope: Scope, message: Message) -> Message: - """ - Set the session cookie in the response headers. - - Args: - scope (Scope): ASGI scope. - message (Message): ASGI message - """ - data = self.encode_session(scope["session"]) - data = self.signer.sign(data) - # we need to update the message - headers = Header.ensure_header_instance(scope=message) - header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( - session_cookie=self.session_cookie, - data=data.decode("utf-8"), - path=self.path, - max_age=f"Max-Age={self.max_age}; " if self.max_age else "", - security_flags=self.security_flags, - ) - headers.add("Set-Cookie", header_value) - return message - - async def clear_session_cookie(self, scope: Scope, message: Message) -> Message: - """ - Clear the session cookie in the response headers. - - Args: - scope (Scope): ASGI scope. - """ - # we need to update the message - headers = Header.ensure_header_instance(scope=message) - header_value = "{session_cookie}={data}; path={path}; {expires}{security_flags}".format( - session_cookie=self.session_cookie, - data="null", - path=self.path, - expires="expires=Thu, 01 Jan 1970 00:00:00 GMT; ", - security_flags=self.security_flags, - ) - headers.add("Set-Cookie", header_value) - return message diff --git a/pyproject.toml b/pyproject.toml index eba1c2d..06fde09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,7 +230,7 @@ source = ["tests", "lilya"] # omit = [] [[tool.mypy.overrides]] -module = ["multipart.*", "mako.*", "uvicorn.*"] +module = ["multipart.*", "mako.*", "uvicorn.*"] ignore_missing_imports = true ignore_errors = true diff --git a/tests/test_responses.py b/tests/test_responses.py index f772cb7..4fe5f88 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -8,8 +8,11 @@ import pytest from lilya import status +from lilya.apps import Lilya from lilya.background import Task from lilya.encoders import Encoder +from lilya.middleware import DefineMiddleware +from lilya.middleware.sessions import SessionMiddleware from lilya.requests import Request from lilya.responses import ( Error, @@ -21,6 +24,7 @@ StreamingResponse, redirect, ) +from lilya.routing import Path from lilya.testclient import TestClient @@ -382,6 +386,108 @@ async def app(scope, receive, send): ) +def test_set_cookie_multiple(test_client_factory, monkeypatch): + # Mock time used as a reference for `Expires` by stdlib `SimpleCookie`. + mocked_now = dt.datetime(2037, 1, 22, 12, 0, 0, tzinfo=dt.timezone.utc) + monkeypatch.setattr(time, "time", lambda: mocked_now.timestamp()) + + async def app(scope, receive, send): + response = Response("Hello, world!", media_type="text/plain", encoders=[FooEncoder]) + assert isinstance(response.encoders[0], FooEncoder) + response.set_cookie( + "access_cookie", + "myvalue", + max_age=10, + expires=10, + path="/", + domain="localhost", + secure=True, + httponly=True, + samesite="none", + ) + response.set_cookie( + "refresh_cookie", + "myvalue", + max_age=10, + expires=10, + path="/", + domain="localhost", + secure=True, + httponly=True, + samesite="none", + ) + await response(scope, receive, send) + + client = test_client_factory(app) + response = client.get("/") + assert response.text == "Hello, world!" + + assert ( + response.headers["set-cookie"] + == "access_cookie=myvalue; Domain=localhost; expires=Thu, 22 Jan 2037 12:00:10 GMT; HttpOnly; Max-Age=10; Path=/; SameSite=none; Secure, refresh_cookie=myvalue; Domain=localhost; expires=Thu, 22 Jan 2037 12:00:10 GMT; HttpOnly; Max-Age=10; Path=/; SameSite=none; Secure" + ) + + +def test_set_cookie_multiple_with_session(test_client_factory, monkeypatch): + # Mock time used as a reference for `Expires` by stdlib `SimpleCookie`. + mocked_now = dt.datetime(2037, 1, 22, 12, 0, 0, tzinfo=dt.timezone.utc) + monkeypatch.setattr(time, "time", lambda: mocked_now.timestamp()) + + async def home(): + response = Response("Hello, world!", media_type="text/plain", encoders=[FooEncoder]) + response.set_cookie( + "access_cookie", + "myvalue", + max_age=10, + expires=10, + path="/", + domain="localhost", + secure=True, + httponly=True, + samesite="none", + ) + response.set_cookie( + "refresh_cookie", + "myvalue", + max_age=10, + expires=10, + path="/", + domain="localhost", + secure=True, + httponly=True, + samesite="none", + ) + return response + + async def update_session(request: Request) -> JSONResponse: + data = await request.json() + request.session.update(data) + return JSONResponse({"session": request.session}) + + lilya_app = Lilya( + routes=[ + Path("/session", update_session, methods=["POST"]), + Path("/", home), + ], + middleware=[ + DefineMiddleware(SessionMiddleware, secret_key="your-secret-key"), + ], + ) + + client = test_client_factory(lilya_app) + + response = client.post("/session", json={"some": "data"}) + assert response.json() == {"session": {"some": "data"}} + + response = client.get("/") + assert response.text == "Hello, world!" + + assert ( + response.headers["set-cookie"] + == "access_cookie=myvalue; Domain=localhost; expires=Thu, 22 Jan 2037 12:00:10 GMT; HttpOnly; Max-Age=10; Path=/; SameSite=none; Secure, refresh_cookie=myvalue; Domain=localhost; expires=Thu, 22 Jan 2037 12:00:10 GMT; HttpOnly; Max-Age=10; Path=/; SameSite=none; Secure" + ) + + @pytest.mark.parametrize( "expires", [