Add websocket session support to kaya-session
- kaya-core: WebSocket ABC gains session attribute and accept(headers=...) - kaya-core: AsgiWebSocket injects headers into websocket.accept message - kaya-rsgi: RsgiWebSocket accepts headers param (ignored — Granian's accept() takes no args) - kaya-session: SessionWebSocket wrapper exposes ws.session and injects Set-Cookie on accept() - kaya-session: SessionMixin registers before/after websocket hooks; session loaded at connect, persisted on close if modified - 10 new WV session tests covering read, persist, handshake cookie, regenerate, invalidate, isolation - Example and README updated
This commit is contained in:
+3
-7
@@ -1,4 +1,4 @@
|
|||||||
from kaya.core import HttpContext, KayaApp
|
from kaya.core import HttpContext, KayaApp, WebSocket
|
||||||
from kaya.session import InMemorySessionStore, SessionMixin
|
from kaya.session import InMemorySessionStore, SessionMixin
|
||||||
|
|
||||||
app = KayaApp(mixins=[SessionMixin(InMemorySessionStore())])
|
app = KayaApp(mixins=[SessionMixin(InMemorySessionStore())])
|
||||||
@@ -35,11 +35,7 @@ async def echo(ws: WebSocket) -> None:
|
|||||||
|
|
||||||
@app.websocket('/ws/visits')
|
@app.websocket('/ws/visits')
|
||||||
async def ws_visits(ws: WebSocket) -> None:
|
async def ws_visits(ws: WebSocket) -> None:
|
||||||
# WebSocket handlers can read the existing session. Most ASGI servers
|
visits = ws.session.get('visits', 0) + 1
|
||||||
# (including Granian and Daphne) do not forward the `headers` field of the
|
ws.session['visits'] = visits
|
||||||
# `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()
|
await ws.accept()
|
||||||
visits = ws.session.get('visits', 0)
|
|
||||||
await ws.send_text(f'visits: {visits}')
|
await ws.send_text(f'visits: {visits}')
|
||||||
|
|||||||
@@ -173,8 +173,13 @@ class AsgiWebSocket(WebSocket):
|
|||||||
self.server = scope['server']
|
self.server = scope['server']
|
||||||
self.headers = decode_headers(scope['headers'])
|
self.headers = decode_headers(scope['headers'])
|
||||||
|
|
||||||
async def accept(self) -> None:
|
async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
await self._send({'type': 'websocket.accept'})
|
message: Dict[str, Any] = {'type': 'websocket.accept'}
|
||||||
|
if headers is not None:
|
||||||
|
# Emit a list rather than a tuple: wsproto's AcceptConnection (used
|
||||||
|
# by httpx_ws and others) requires list concatenation.
|
||||||
|
message['headers'] = list(encode_headers(headers))
|
||||||
|
await self._send(message)
|
||||||
|
|
||||||
async def receive(self) -> WebSocketMessage:
|
async def receive(self) -> WebSocketMessage:
|
||||||
message = await self._receive()
|
message = await self._receive()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncIterator, Literal, Mapping, Optional, Sequence, Tuple, Union
|
from typing import Any, AsyncIterator, Literal, Mapping, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
from ._types.base import StrOrStrings
|
||||||
|
|
||||||
type WebSocketData = Optional[Union[str, bytes, int]]
|
type WebSocketData = Optional[Union[str, bytes, int]]
|
||||||
|
|
||||||
@@ -18,9 +20,10 @@ class WebSocket(ABC):
|
|||||||
headers: Mapping[str, Sequence[str]]
|
headers: Mapping[str, Sequence[str]]
|
||||||
client: Optional[Tuple[str, int]]
|
client: Optional[Tuple[str, int]]
|
||||||
server: Optional[Tuple[str, Optional[int]]]
|
server: Optional[Tuple[str, Optional[int]]]
|
||||||
|
session: Optional[Any] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def accept(self) -> None:
|
async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pwo import async_test
|
|||||||
from httpx_ws import aconnect_ws, WebSocketDisconnect
|
from httpx_ws import aconnect_ws, WebSocketDisconnect
|
||||||
from httpx_ws.transport import ASGIWebSocketTransport
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
from kaya.core import KayaApp, WebSocket
|
from kaya.core import KayaApp, WebSocket
|
||||||
|
from kaya.core._asgi import AsgiWebSocket
|
||||||
|
|
||||||
|
|
||||||
class WebSocketTest(unittest.TestCase):
|
class WebSocketTest(unittest.TestCase):
|
||||||
@@ -78,3 +79,55 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
async with aconnect_ws("/echo", client) as ws:
|
async with aconnect_ws("/echo", client) as ws:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_accept_with_headers(self):
|
||||||
|
sent_messages = []
|
||||||
|
|
||||||
|
async def send(message):
|
||||||
|
sent_messages.append(message)
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {'type': 'websocket.connect'}
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
'type': 'websocket',
|
||||||
|
'path': '/echo',
|
||||||
|
'query_string': b'',
|
||||||
|
'scheme': 'ws',
|
||||||
|
'client': ('127.0.0.1', 12345),
|
||||||
|
'server': ('127.0.0.1', 80),
|
||||||
|
'headers': [],
|
||||||
|
}
|
||||||
|
ws = AsgiWebSocket(scope, receive, send)
|
||||||
|
await ws.accept(headers={'Set-Cookie': 'sid=abc; Path=/', 'X-Custom': ('a', 'b')})
|
||||||
|
self.assertEqual(1, len(sent_messages))
|
||||||
|
message = sent_messages[0]
|
||||||
|
self.assertEqual('websocket.accept', message['type'])
|
||||||
|
self.assertIn((b'Set-Cookie', b'sid=abc; Path=/'), message['headers'])
|
||||||
|
self.assertIn((b'X-Custom', b'a'), message['headers'])
|
||||||
|
self.assertIn((b'X-Custom', b'b'), message['headers'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_accept_without_headers(self):
|
||||||
|
sent_messages = []
|
||||||
|
|
||||||
|
async def send(message):
|
||||||
|
sent_messages.append(message)
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {'type': 'websocket.connect'}
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
'type': 'websocket',
|
||||||
|
'path': '/echo',
|
||||||
|
'query_string': b'',
|
||||||
|
'scheme': 'ws',
|
||||||
|
'client': ('127.0.0.1', 12345),
|
||||||
|
'server': ('127.0.0.1', 80),
|
||||||
|
'headers': [],
|
||||||
|
}
|
||||||
|
ws = AsgiWebSocket(scope, receive, send)
|
||||||
|
await ws.accept()
|
||||||
|
self.assertEqual(1, len(sent_messages))
|
||||||
|
self.assertEqual({'type': 'websocket.accept'}, sent_messages[0])
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ class RsgiWebSocket(WebSocket):
|
|||||||
.map(lambda it: (it[0], int(it[1])))
|
.map(lambda it: (it[0], int(it[1])))
|
||||||
.or_else_throw(RuntimeError))
|
.or_else_throw(RuntimeError))
|
||||||
|
|
||||||
async def accept(self) -> None:
|
async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
# RSGI's websocket accept() takes no arguments (https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md),
|
||||||
|
# so handshake headers (e.g. Set-Cookie) cannot be sent on RSGI; they are silently ignored here.
|
||||||
self._transport = await self._protocol.accept()
|
self._transport = await self._protocol.accept()
|
||||||
|
|
||||||
async def receive(self) -> WebSocketMessage:
|
async def receive(self) -> WebSocketMessage:
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ session.
|
|||||||
`SessionMixin` is a `KayaMixin`, so the app stays a `KayaApp` and both ASGI and
|
`SessionMixin` is a `KayaMixin`, so the app stays a `KayaApp` and both ASGI and
|
||||||
RSGI keep working.
|
RSGI keep working.
|
||||||
|
|
||||||
|
## WebSocket sessions
|
||||||
|
|
||||||
|
The same session is available in websocket handlers as `ws.session`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.websocket('/ws/visits')
|
||||||
|
async def ws_visits(ws: WebSocket):
|
||||||
|
visits = ws.session.get('visits', 0) + 1
|
||||||
|
ws.session['visits'] = visits
|
||||||
|
await ws.accept()
|
||||||
|
await ws.send_text(f'visits: {visits}')
|
||||||
|
```
|
||||||
|
|
||||||
|
The session is loaded from the cookie when the connection is opened and
|
||||||
|
persisted when the connection closes, if it was modified. The session cookie
|
||||||
|
can only be set or refreshed on the handshake response, so mutate the session
|
||||||
|
*before* calling `ws.accept()` if you want the cookie delivered with the
|
||||||
|
handshake. Handshake cookies require ASGI spec version 2.1+; RSGI websocket
|
||||||
|
handshakes cannot carry response headers, so on RSGI the session is loaded and
|
||||||
|
persisted but the cookie is only set or refreshed by HTTP responses.
|
||||||
|
|
||||||
## Session expiry
|
## Session expiry
|
||||||
|
|
||||||
The cookie sent to the browser has a `Max-Age` (default 14 days), but that is
|
The cookie sent to the browser has a `Max-Age` (default 14 days), but that is
|
||||||
@@ -50,6 +71,8 @@ attribute) entirely.
|
|||||||
- `SessionMixin`: composable Kaya mixin managing session cookies and persistence
|
- `SessionMixin`: composable Kaya mixin managing session cookies and persistence
|
||||||
- Session ID regeneration (`session.regenerate_id()`) and invalidation
|
- Session ID regeneration (`session.regenerate_id()`) and invalidation
|
||||||
(`session.invalidate()`) for authentication layers
|
(`session.invalidate()`) for authentication layers
|
||||||
|
- WebSocket support: the session is exposed as `ws.session` in websocket
|
||||||
|
handlers, loaded at connect time and persisted on close
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Optional, Sequence
|
from typing import Any, AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Optional, Sequence
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp, KayaMixin
|
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
|
||||||
from kaya.core._types import StrOrStrings
|
from kaya.core._types import StrOrStrings
|
||||||
|
|
||||||
from ._cookie import format_set_cookie, parse_cookie_value
|
from ._cookie import format_set_cookie, parse_cookie_value
|
||||||
@@ -9,6 +9,23 @@ from ._session import Session
|
|||||||
from ._store import SessionStore
|
from ._store import SessionStore
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_cookie_header(
|
||||||
|
headers: Optional[Mapping[str, StrOrStrings]],
|
||||||
|
cookie_value: Optional[str],
|
||||||
|
) -> Optional[Mapping[str, StrOrStrings]]:
|
||||||
|
if cookie_value is None:
|
||||||
|
return headers
|
||||||
|
new_headers: dict[str, StrOrStrings] = dict(headers) if headers else {}
|
||||||
|
existing = new_headers.get('Set-Cookie')
|
||||||
|
if existing is None:
|
||||||
|
new_headers['Set-Cookie'] = cookie_value
|
||||||
|
elif isinstance(existing, str):
|
||||||
|
new_headers['Set-Cookie'] = (existing, cookie_value)
|
||||||
|
else:
|
||||||
|
new_headers['Set-Cookie'] = (*existing, cookie_value)
|
||||||
|
return new_headers
|
||||||
|
|
||||||
|
|
||||||
class SessionHttpContext(HttpContext):
|
class SessionHttpContext(HttpContext):
|
||||||
"""HttpContext wrapper that exposes ``session`` and injects the session
|
"""HttpContext wrapper that exposes ``session`` and injects the session
|
||||||
cookie into response headers.
|
cookie into response headers.
|
||||||
@@ -37,18 +54,7 @@ class SessionHttpContext(HttpContext):
|
|||||||
return getattr(self._ctx, name)
|
return getattr(self._ctx, name)
|
||||||
|
|
||||||
def _inject_cookie(self, headers: Optional[Mapping[str, StrOrStrings]]) -> Optional[Mapping[str, StrOrStrings]]:
|
def _inject_cookie(self, headers: Optional[Mapping[str, StrOrStrings]]) -> Optional[Mapping[str, StrOrStrings]]:
|
||||||
cookie_value = self._cookie_injector()
|
return _merge_cookie_header(headers, self._cookie_injector())
|
||||||
if cookie_value is None:
|
|
||||||
return headers
|
|
||||||
new_headers: dict[str, StrOrStrings] = dict(headers) if headers else {}
|
|
||||||
existing = new_headers.get('Set-Cookie')
|
|
||||||
if existing is None:
|
|
||||||
new_headers['Set-Cookie'] = cookie_value
|
|
||||||
elif isinstance(existing, str):
|
|
||||||
new_headers['Set-Cookie'] = (existing, cookie_value)
|
|
||||||
else:
|
|
||||||
new_headers['Set-Cookie'] = (*existing, cookie_value)
|
|
||||||
return new_headers
|
|
||||||
|
|
||||||
async def stream_body(self,
|
async def stream_body(self,
|
||||||
status: int,
|
status: int,
|
||||||
@@ -69,6 +75,55 @@ class SessionHttpContext(HttpContext):
|
|||||||
await self._ctx.send_empty(status, self._inject_cookie(headers))
|
await self._ctx.send_empty(status, self._inject_cookie(headers))
|
||||||
|
|
||||||
|
|
||||||
|
class SessionWebSocket(WebSocket):
|
||||||
|
"""WebSocket wrapper that exposes ``session`` and injects the session
|
||||||
|
cookie into the handshake response headers on ``accept()``.
|
||||||
|
|
||||||
|
Works with any concrete ``WebSocket`` (ASGI or RSGI) because it only
|
||||||
|
relies on the abstract methods, which all implementations share.
|
||||||
|
Attributes not explicitly overridden are delegated to the wrapped socket
|
||||||
|
via ``__getattr__``.
|
||||||
|
|
||||||
|
The cookie is only sent if the underlying transport supports handshake
|
||||||
|
response headers: ASGI does (spec version 2.1+), RSGI does not, so on
|
||||||
|
RSGI the session is still loaded and persisted but no cookie is set or
|
||||||
|
refreshed from a websocket connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ws: WebSocket,
|
||||||
|
session: Session,
|
||||||
|
cookie_injector: Callable[[], Optional[str]],
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, '_ws', ws)
|
||||||
|
object.__setattr__(self, 'session', session)
|
||||||
|
object.__setattr__(self, '_cookie_injector', cookie_injector)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name == '_ws':
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self._ws, name)
|
||||||
|
|
||||||
|
async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ws.accept(_merge_cookie_header(headers, self._cookie_injector()))
|
||||||
|
|
||||||
|
async def receive(self) -> Any:
|
||||||
|
return await self._ws.receive()
|
||||||
|
|
||||||
|
async def send_text(self, data: str) -> None:
|
||||||
|
await self._ws.send_text(data)
|
||||||
|
|
||||||
|
async def send_bytes(self, data: bytes) -> None:
|
||||||
|
await self._ws.send_bytes(data)
|
||||||
|
|
||||||
|
async def close(self, code: int = 1000) -> None:
|
||||||
|
await self._ws.close(code)
|
||||||
|
|
||||||
|
async def __anext__(self) -> Any:
|
||||||
|
return await self._ws.__anext__()
|
||||||
|
|
||||||
|
|
||||||
class _CookieInjector:
|
class _CookieInjector:
|
||||||
"""Computes the Set-Cookie value once (on first response) and caches it."""
|
"""Computes the Set-Cookie value once (on first response) and caches it."""
|
||||||
|
|
||||||
@@ -88,9 +143,16 @@ class _CookieInjector:
|
|||||||
class SessionMixin(KayaMixin):
|
class SessionMixin(KayaMixin):
|
||||||
"""Kaya mixin providing server-side HTTP sessions.
|
"""Kaya mixin providing server-side HTTP sessions.
|
||||||
|
|
||||||
Registers before/after request hooks that load and persist the session and
|
Registers before/after request and websocket hooks that load and persist
|
||||||
injects the session cookie into responses via a wrapped ``HttpContext``.
|
the session, injecting the session cookie into HTTP responses via a
|
||||||
Because the app stays a ``KayaApp``, both ASGI and RSGI keep working.
|
wrapped ``HttpContext`` and into websocket handshake responses via a
|
||||||
|
wrapped ``WebSocket``. Because the app stays a ``KayaApp``, both ASGI and
|
||||||
|
RSGI keep working.
|
||||||
|
|
||||||
|
For websockets the session is loaded when the connection is opened and
|
||||||
|
persisted when it closes if modified. The session cookie can only be set
|
||||||
|
or refreshed on the handshake response (ASGI only; RSGI websocket
|
||||||
|
handshakes cannot carry response headers).
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
@@ -124,15 +186,11 @@ class SessionMixin(KayaMixin):
|
|||||||
def apply(self, app: KayaApp) -> None:
|
def apply(self, app: KayaApp) -> None:
|
||||||
app.add_before_request_hook(self._before_request)
|
app.add_before_request_hook(self._before_request)
|
||||||
app.add_after_request_hook(self._after_request)
|
app.add_after_request_hook(self._after_request)
|
||||||
|
app.add_before_websocket_hook(self._before_websocket)
|
||||||
|
app.add_after_websocket_hook(self._after_websocket)
|
||||||
|
|
||||||
async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]:
|
async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]:
|
||||||
session_id = self._extract_session_id(ctx)
|
session = await self._load_session(ctx.headers)
|
||||||
session: Session
|
|
||||||
if session_id is not None:
|
|
||||||
loaded = await self._store.load(session_id, self._max_age)
|
|
||||||
session = loaded if loaded is not None else Session()
|
|
||||||
else:
|
|
||||||
session = Session()
|
|
||||||
injector = _CookieInjector(self, session)
|
injector = _CookieInjector(self, session)
|
||||||
return SessionHttpContext(ctx, session, injector)
|
return SessionHttpContext(ctx, session, injector)
|
||||||
|
|
||||||
@@ -142,8 +200,27 @@ class SessionMixin(KayaMixin):
|
|||||||
return
|
return
|
||||||
await self._persist(session)
|
await self._persist(session)
|
||||||
|
|
||||||
def _extract_session_id(self, ctx: HttpContext) -> Optional[str]:
|
async def _before_websocket(self, ws: WebSocket) -> Optional[WebSocket]:
|
||||||
cookie_header_values = ctx.headers.get('cookie')
|
session = await self._load_session(ws.headers)
|
||||||
|
injector = _CookieInjector(self, session)
|
||||||
|
return SessionWebSocket(ws, session, injector)
|
||||||
|
|
||||||
|
async def _after_websocket(self, ws: WebSocket) -> None:
|
||||||
|
session = ws.session
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
return
|
||||||
|
await self._persist(session)
|
||||||
|
|
||||||
|
async def _load_session(self, headers: Mapping[str, Sequence[str]]) -> Session:
|
||||||
|
session_id = self._extract_session_id(headers)
|
||||||
|
if session_id is not None:
|
||||||
|
loaded = await self._store.load(session_id, self._max_age)
|
||||||
|
if loaded is not None:
|
||||||
|
return loaded
|
||||||
|
return Session()
|
||||||
|
|
||||||
|
def _extract_session_id(self, headers: Mapping[str, Sequence[str]]) -> Optional[str]:
|
||||||
|
cookie_header_values = headers.get('cookie')
|
||||||
if cookie_header_values is None:
|
if cookie_header_values is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(cookie_header_values, str):
|
if isinstance(cookie_header_values, str):
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pwo import async_test
|
||||||
|
from httpx_ws import aconnect_ws
|
||||||
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
|
from kaya.core import KayaApp, HttpContext, WebSocket
|
||||||
|
|
||||||
|
from kaya.session import InMemorySessionStore, SessionMixin
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketSessionTest(unittest.TestCase):
|
||||||
|
app: KayaApp
|
||||||
|
store: InMemorySessionStore
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.store = InMemorySessionStore()
|
||||||
|
self.app = KayaApp(mixins=[SessionMixin(self.store)])
|
||||||
|
|
||||||
|
@self.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.app.GET('/read')
|
||||||
|
async def read(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, f"visits: {ctx.session.get('visits', 0)}"
|
||||||
|
f" ws_seen: {ctx.session.get('ws_seen', False)}")
|
||||||
|
|
||||||
|
@self.app.websocket('/visits')
|
||||||
|
async def ws_visits(ws: WebSocket) -> None:
|
||||||
|
await ws.accept()
|
||||||
|
await ws.send_text(f"visits: {ws.session.get('visits', 0)}")
|
||||||
|
|
||||||
|
@self.app.websocket('/mark')
|
||||||
|
async def ws_mark(ws: WebSocket) -> None:
|
||||||
|
await ws.accept()
|
||||||
|
ws.session['ws_seen'] = True
|
||||||
|
await ws.send_text('marked')
|
||||||
|
|
||||||
|
@self.app.websocket('/handshake-write')
|
||||||
|
async def ws_handshake_write(ws: WebSocket) -> None:
|
||||||
|
ws.session['ws_seen'] = True
|
||||||
|
await ws.accept()
|
||||||
|
await ws.send_text('marked')
|
||||||
|
|
||||||
|
@self.app.websocket('/peek')
|
||||||
|
async def ws_peek(ws: WebSocket) -> None:
|
||||||
|
await ws.accept()
|
||||||
|
await ws.send_text(f"visits: {ws.session.get('visits', 0)}")
|
||||||
|
|
||||||
|
@self.app.websocket('/rotate')
|
||||||
|
async def ws_rotate(ws: WebSocket) -> None:
|
||||||
|
old_id = ws.session.id
|
||||||
|
ws.session.regenerate_id()
|
||||||
|
await ws.accept()
|
||||||
|
await ws.send_text(f'old: {old_id} new: {ws.session.id}')
|
||||||
|
|
||||||
|
@self.app.websocket('/clear')
|
||||||
|
async def ws_clear(ws: WebSocket) -> None:
|
||||||
|
ws.session.invalidate()
|
||||||
|
await ws.accept()
|
||||||
|
await ws.send_text('cleared')
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_session_loaded_from_cookie(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
|
||||||
|
async with aconnect_ws('/visits', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('visits: 1', message)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_session_persisted_on_close(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
await client.get('/')
|
||||||
|
async with aconnect_ws('/mark', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('marked', message)
|
||||||
|
|
||||||
|
r = await client.get('/read')
|
||||||
|
self.assertEqual('visits: 1 ws_seen: True', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_handshake_sets_cookie(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
self.assertNotIn('session_id', client.cookies)
|
||||||
|
async with aconnect_ws('/handshake-write', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('marked', message)
|
||||||
|
|
||||||
|
self.assertIn('session_id', client.cookies)
|
||||||
|
self.assertIn(client.cookies['session_id'], self.store._data)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_no_cookie_when_session_not_modified(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
async with aconnect_ws('/peek', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('visits: 0', message)
|
||||||
|
|
||||||
|
self.assertNotIn('session_id', client.cookies)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_session_unmodified_not_persisted(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
async with aconnect_ws('/peek', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('visits: 0', message)
|
||||||
|
|
||||||
|
self.assertEqual(0, len(self.store._data))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_session_new_session_saved_on_close(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
self.assertNotIn('session_id', client.cookies)
|
||||||
|
async with aconnect_ws('/mark', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('marked', message)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(self.store._data))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_session_regenerate_id(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
await client.get('/')
|
||||||
|
old_id = client.cookies['session_id']
|
||||||
|
self.assertIn(old_id, self.store._data)
|
||||||
|
|
||||||
|
new_id = None
|
||||||
|
async with aconnect_ws('/rotate', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
old_part, new_part = message.split(' new: ')
|
||||||
|
self.assertEqual(f'old: {old_id}', old_part)
|
||||||
|
new_id = new_part
|
||||||
|
self.assertNotEqual(old_id, new_id)
|
||||||
|
|
||||||
|
self.assertIsNotNone(new_id)
|
||||||
|
self.assertNotIn(old_id, self.store._data)
|
||||||
|
self.assertIn(new_id, self.store._data)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_session_invalidate(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
await client.get('/')
|
||||||
|
old_id = client.cookies['session_id']
|
||||||
|
self.assertIn(old_id, self.store._data)
|
||||||
|
|
||||||
|
async with aconnect_ws('/clear', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('cleared', message)
|
||||||
|
|
||||||
|
self.assertNotIn(old_id, self.store._data)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_sessions_are_isolated(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
await client.get('/')
|
||||||
|
async with aconnect_ws('/visits', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('visits: 1', message)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
async with aconnect_ws('/visits', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('visits: 0', message)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ws_invalid_cookie_creates_fresh_session(self) -> None:
|
||||||
|
transport = ASGIWebSocketTransport(app=self.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
client.cookies.set('session_id', 'not-a-real-id')
|
||||||
|
async with aconnect_ws('/mark', client) as ws:
|
||||||
|
message = await ws.receive_text()
|
||||||
|
self.assertEqual('marked', message)
|
||||||
|
|
||||||
|
self.assertNotIn('not-a-real-id', self.store._data)
|
||||||
|
self.assertEqual(1, len(self.store._data))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user