Session values no longer bind to HTTP or WebSocket handler parameters. Access them explicitly through self.request.session.

Commit 10cd933 · patx · 2026-07-14T21:39:28-04:00

Changeset
10cd9336956569a55337c058a688cc523f13fe41
Parents
a686ec89043110d225b897f9e5ea779d4fa3ab0a

View source at this commit

Session values no longer bind to HTTP or WebSocket handler parameters. Access them explicitly through self.request.session.
Query strings and URL-encoded forms now: Preserve valid blank values. Reject missing =, invalid percent escapes, invalid UTF-8, and non-ASCII raw input. Return HTTP 400 or WebSocket close code 1008 when malformed.
JSON object keys still support argument binding but retain native types. JSON is no longer mirrored into body_params, so request.form() only reads actual form data.

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 62c9cf4..f4c908f 100644
--- a/README.md
+++ b/README.md
@@ -115,9 +115,10 @@ MicroPie's route handlers map URLs to methods in your `App` subclass. Handler in
 - **Automatic Mapping**: URLs map to method names (e.g., `/greet` → `greet`, `/` → `index`).
 - **Private Methods**: Methods starting with `_` (e.g., `_private_method`) are private and inaccessible via URLs, returning 404. **Security Note**: Use `_` for sensitive methods to prevent external access.
 - **Automatic Argument Binding**: Handler args are populated from path/query/body data by parameter name.
+- **Session Isolation**: Session values never bind to handler arguments; read trusted state explicitly from `self.request.session`.
 - **Request Helpers**:
   - `self.request.query(name, default)` for query-string values.
-  - `self.request.form(name, default)` for form/body values.
+  - `self.request.form(name, default)` for URL-encoded or multipart form values.
   - `self.request.json()` for full JSON payloads, or `self.request.json(name, default)` for a key lookup.
 - **HTTP Methods**: Handlers support all methods (GET, POST, etc.). Check `self.request.method` to handle specific methods.
 - **Responses**:
@@ -146,6 +147,11 @@ class MyApp(App):
 - POST `application/x-www-form-urlencoded` to `/submit` with `username=bob` returns `Submitted by: bob`.
 - POST `application/json` to `/submit` with `{"username": "bob"}` also returns `Submitted by: bob`.
 
+Top-level JSON values retain their native types during binding. JSON objects
+are not mirrored into `body_params`, so `request.form()` only reads form data.
+Malformed query strings and URL-encoded forms return `400 Bad Request`; valid
+empty values such as `name=` are preserved.
+
 #### **Helper-Based Request Access**
 Use helper methods for query, form, and JSON payload access:
 ```python
@@ -170,7 +176,7 @@ class MyApp(App):
 
 Choose the simplest input style that fits the route:
 - Use handler arguments when you only need named path/query/body values.
-- Use helpers when you want to explicitly read one query value, one body value, or a JSON payload.
+- Use helpers when you want to explicitly read one query value, one form value, or a JSON payload.
 - Use raw attributes like `query_params`, `body_params`, `files`, `headers`, and `session` when you need repeated values, uploaded file streams, middleware-level inspection, or exact parsed structures.
 
 Do not `await` `self.request.query(...)`, `self.request.form(...)`, or `self.request.json(...)`. They are synchronous accessors over request data MicroPie has already parsed. Uploaded file content is the exception: file bodies are streamed through async queues in `self.request.files`.
@@ -215,7 +221,7 @@ MicroPie includes built-in support for WebSocket connections. WebSocket routes a
 
 #### MicroPie’s WebSocket support allows you to:
 - Define WebSocket handlers with the same intuitive automatic routing as HTTP (e.g., `/chat` maps to `ws_chat` method).
-- Access query parameters, path parameters, and session data in WebSocket handlers, consistent with HTTP requests.
+- Access query and path parameters through handler arguments, and session data explicitly through `self.request.session`.
 - Manage WebSocket connections using the WebSocket class, which provides methods like `accept`, `receive_text`, `send_text`, and `close`.
 
 Check out a basic example:
diff --git a/docs/apidocs/reference/request.rst b/docs/apidocs/reference/request.rst
index 67f8813..7c051a6 100644
--- a/docs/apidocs/reference/request.rst
+++ b/docs/apidocs/reference/request.rst
@@ -67,11 +67,11 @@ Request class
 
    .. attribute:: body_params
 
-      A ``dict`` mapping form field names to lists of values.  For
-      JSON requests, body parameters are derived from the top-level
-      object; for ``application/x-www-form-urlencoded`` forms they are
-      parsed using :func:`urllib.parse.parse_qs`.  This is the raw
-      mapping used by MicroPie for argument binding.
+      A ``dict`` mapping URL-encoded and multipart form field names to
+      lists of string values.  JSON objects are not mirrored into this
+      mapping; use :meth:`json` for JSON access.  URL-encoded forms are
+      parsed strictly, preserving empty values and rejecting malformed
+      fields or encoding with ``400 Bad Request``.
 
       Use this raw attribute when you need all submitted values for a
       field, or when middleware needs to inspect parsed request fields:
@@ -82,13 +82,11 @@ Request class
 
    .. method:: form(name, default=None)
 
-      Return the first value for form/body field *name*, or *default*
-      if missing.
+      Return the first URL-encoded or multipart form value for *name*,
+      or *default* if missing.
 
       This helper reads from :attr:`body_params`, so it returns the
-      first parsed body value.  It is commonly used for HTML form fields,
-      but top-level JSON object keys are also mirrored into
-      :attr:`body_params` for argument binding.
+      first parsed form value.  It does not read JSON objects.
 
       .. code-block:: python
 
@@ -112,11 +110,15 @@ Request class
 
       Do not await this method.  MicroPie parses JSON before calling the
       route handler.  Invalid JSON requests receive ``400 Bad Request``.
+      Top-level JSON object keys can bind directly to handler arguments
+      and retain native JSON types; nested values are not stringified.
 
    .. attribute:: session
 
       A ``dict`` for storing per‑client data across requests.  See
-      :doc:`../howto/sessions`.
+      :doc:`../howto/sessions`.  Session values are deliberately excluded
+      from automatic handler argument binding; access them explicitly
+      through this attribute.
 
    .. attribute:: files
 
diff --git a/docs/apidocs/tutorial/routing.rst b/docs/apidocs/tutorial/routing.rst
index cf5d1a7..9162878 100644
--- a/docs/apidocs/tutorial/routing.rst
+++ b/docs/apidocs/tutorial/routing.rst
@@ -4,7 +4,7 @@ Routing and handlers
 MicroPie maps incoming HTTP requests to methods on your :class:`~micropie.App`
 subclass.  This section explains how the mapping works and how your
 handlers receive input from the URL path, query strings, form data and
-sessions.
+JSON bodies.  Session state is accessed explicitly through the request.
 
 URL to function mapping
 -----------------------
@@ -48,23 +48,35 @@ sources in the following order:
    ``/greet?name=Alice`` also passes ``"Alice"`` to the ``name``
    parameter.
 
-3. **Body/form data** – For POST, PUT and PATCH requests, form fields
-   populate handler arguments.  JSON bodies are decoded into a
-   dictionary of key/value pairs.
+3. **Body data** – For POST, PUT and PATCH requests, form fields populate
+   handler arguments as strings.  Keys from a top-level JSON object also
+   bind by name, retaining their native JSON types, including nested
+   objects, arrays, numbers, booleans and ``null``.
 
 4. **Files** – If the request is multipart/form‑data, uploaded files
    appear in :attr:`~micropie.Request.files`.  You can declare a file
    argument in your handler signature to receive a file object.
 
-5. **Session data** – Values stored in :attr:`~micropie.Request.session`
-   fill remaining parameters.  See :doc:`../howto/sessions` for details.
-
-6. **Default values** – If no other source provides a value, default
+5. **Default values** – If no request source provides a value, default
    values in your function signature are used.
 
 If MicroPie cannot determine a value for a required parameter, it
 returns a ``400 Bad Request``.
 
+Session values never participate in automatic argument binding.  This
+keeps untrusted request inputs and trusted session state in separate
+namespaces.  Read session values explicitly inside the handler:
+
+.. code-block:: python
+
+   class MyApp(App):
+       async def transfer(self, amount):
+           user_id = self.request.session["user_id"]
+           return await make_transfer(user_id, amount)
+
+An extra ``user_id`` query or body field cannot replace the value read
+from the session in this example.
+
 Choosing an input access style
 ------------------------------
 
@@ -110,8 +122,7 @@ simplest level that fits the handler.
    * ``self.request.query(name, default=None)``: first query-string
      value for ``name``.
    * ``self.request.form(name, default=None)``: first parsed body value
-     for ``name`` from form data, or from a top-level JSON object mirrored
-     into body parameters.
+     for ``name`` from URL-encoded or multipart form data.
    * ``self.request.json(name=None, default=None)``: full parsed JSON
      payload when called with no name, or one top-level JSON object value
      when ``name`` is provided.
@@ -124,8 +135,8 @@ simplest level that fits the handler.
    * ``self.request.query_params``: ``dict[str, list[str]]`` parsed from
      the query string.
    * ``self.request.body_params``: ``dict[str, list[str]]`` parsed from
-     form-urlencoded payloads, multipart text fields, or mirrored from
-     top-level JSON objects.
+     form-urlencoded payloads or multipart text fields.  JSON data is
+     available only through ``json()`` and native JSON argument binding.
    * ``self.request.session``: session dictionary.
    * ``self.request.files``: uploaded multipart files.
    * ``self.request.headers``: lower-case request header mapping.
@@ -139,6 +150,12 @@ simplest level that fits the handler.
               tags = self.request.query_params.get("tag", [])
               return {"tags": tags}
 
+Query strings and URL-encoded forms are parsed strictly.  Empty values
+such as ``name=`` are preserved, while fields without ``=``, invalid
+percent escapes and invalid UTF-8 receive ``400 Bad Request``.  A
+WebSocket request with a malformed query string is closed with code
+``1008``.
+
 Do not await request helpers
 ----------------------------
 
diff --git a/micropie.py b/micropie.py
index 59a97d8..b7d40d0 100644
--- a/micropie.py
+++ b/micropie.py
@@ -58,6 +58,7 @@ _JSON_HEADER_BYTES = (b"Content-Type", b"application/json")
 _DEFAULT_HEADERS_BYTES = [_DEFAULT_HEADER_BYTES]
 _JSON_HEADERS_BYTES = [_JSON_HEADER_BYTES]
 _ROUTE_ATTR_MISSING = object()
+_INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})")
 
 
 def _format_session_cookie(session_id: str, *, secure: bool) -> str:
@@ -72,6 +73,26 @@ def _format_session_cookie(session_id: str, *, secure: bool) -> str:
     return "; ".join(attributes) + ";"
 
 
+def _parse_urlencoded(data: bytes) -> Dict[str, List[str]]:
+    """Parse URL-encoded data without dropping blanks or malformed fields."""
+    try:
+        encoded = data.decode("ascii")
+    except UnicodeDecodeError as exc:
+        raise ValueError("URL-encoded input must be ASCII") from exc
+    if _INVALID_PERCENT_ESCAPE.search(encoded):
+        raise ValueError("Invalid percent escape in URL-encoded input")
+    try:
+        return parse_qs(
+            encoded,
+            keep_blank_values=True,
+            strict_parsing=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+    except (UnicodeDecodeError, ValueError) as exc:
+        raise ValueError("Malformed URL-encoded input") from exc
+
+
 class _HandlerParam(NamedTuple):
     name: str
     kind: Any
@@ -594,7 +615,7 @@ class App:
         query_string = scope.get("query_string", b"")
         if not query_string:
             return {}
-        return parse_qs(query_string.decode("utf-8", "ignore"))
+        return _parse_urlencoded(query_string)
 
     async def __call__(
         self,
@@ -715,7 +736,11 @@ class App:
 
         try:
             # Parse query/cookies/session
-            request.query_params = self._parse_query_string(scope)
+            try:
+                request.query_params = self._parse_query_string(scope)
+            except ValueError:
+                await _early_exit(400, "400 Bad Request: Malformed query string")
+                return
             cookie_header = request.headers.get("cookie", "")
             cookies = self._parse_cookies(cookie_header) if cookie_header else {}
             request.session = await self._load_session_from_scope(scope, cookies)
@@ -785,17 +810,17 @@ class App:
                     if "application/json" in content_type:
                         try:
                             request._json = json.loads(body_data)
-                            if isinstance(request._json, dict):
-                                request.body_params = {
-                                    k: [str(v)] for k, v in request._json.items()
-                                }
                         except Exception:
                             await _early_exit(400, "400 Bad Request: Bad JSON")
                             return
                     else:
-                        request.body_params = parse_qs(
-                            body_data.decode("utf-8", "ignore")
-                        )
+                        try:
+                            request.body_params = _parse_urlencoded(body_data)
+                        except ValueError:
+                            await _early_exit(
+                                400, "400 Bad Request: Malformed form body"
+                            )
+                            return
                     request.body_parsed = True
 
             # HTTP middlewares (before)
@@ -869,15 +894,16 @@ class App:
                     return
                 request.path_params = parts  # Pass all path parts to index handler
 
-            # Build handler args (query/body/files/session)
+            # Build handler args from untrusted request inputs only. Session
+            # state stays in request.session and is never part of this namespace.
             path_params = request.path_params
             path_param_index = 0
             path_param_count = len(path_params)
             is_multipart = "multipart/form-data" in content_type
             query_params = request.query_params
             body_params = request.body_params
+            json_params = request._json if isinstance(request._json, dict) else {}
             files = request.files
-            session = request.session
 
             for param in handler_info.params:
                 if param.kind == _VAR_POSITIONAL:
@@ -894,6 +920,8 @@ class App:
                     param_value = query_params[param.name][0]
                 elif param.name in body_params:
                     param_value = body_params[param.name][0]
+                elif param.name in json_params:
+                    param_value = json_params[param.name]
                 elif param.name in files:
                     param_value = files[param.name]
                 elif is_multipart:
@@ -906,8 +934,6 @@ class App:
                         return
                     if param_value is None:
                         param_value = param.default
-                elif param.name in session:
-                    param_value = session[param.name]
                 elif param.default is not _PARAM_EMPTY:
                     param_value = param.default
                 else:
@@ -1075,7 +1101,13 @@ class App:
         token = current_request.set(request)
         try:
             # Parse request details (query params, cookies, session)
-            request.query_params = self._parse_query_string(scope)
+            try:
+                request.query_params = self._parse_query_string(scope)
+            except ValueError:
+                await self._send_websocket_close(
+                    send, 1008, "Malformed query string"
+                )
+                return
             cookie_header = request.headers.get("cookie", "")
             cookies = self._parse_cookies(cookie_header) if cookie_header else {}
             request.session = await self._load_session_from_scope(scope, cookies)
@@ -1119,7 +1151,6 @@ class App:
             path_param_index = 0
             path_param_count = len(path_params)
             query_params = request.query_params
-            session = request.session
             ws = WebSocket(
                 receive,
                 send,
@@ -1139,8 +1170,6 @@ class App:
                     path_param_index += 1
                 elif param.name in query_params:
                     param_value = query_params[param.name][0]
-                elif param.name in session:
-                    param_value = session[param.name]
                 elif param.default is not _PARAM_EMPTY:
                     param_value = param.default
                 else:
diff --git a/tests.py b/tests.py
index f7485f4..54de270 100644
--- a/tests.py
+++ b/tests.py
@@ -402,14 +402,14 @@ class TestFastPaths(MicroPieTestCase):
         )
 
     async def test_http_argument_binding_fast_path_matches_existing_sources(self):
-        """Path, query, body, session, and defaults should still bind in order."""
+        """Request inputs bind while trusted session state is read explicitly."""
 
-        async def combine(self, path_value, query_value, body_value, user, suffix="ok"):
+        async def combine(self, path_value, query_value, body_value, suffix="ok"):
             return {
                 "path": path_value,
                 "query": query_value,
                 "body": body_value,
-                "user": user,
+                "user": self.request.session["user"],
                 "suffix": suffix,
             }
 
@@ -449,6 +449,58 @@ class TestFastPaths(MicroPieTestCase):
             },
         )
 
+    async def test_session_values_do_not_bind_http_handler_parameters(self):
+        """A required request argument must not fall through to session state."""
+
+        async def transfer(self, amount, user_id):
+            return f"{user_id}:{amount}"
+
+        setattr(self.app, "transfer", transfer.__get__(self.app, App))
+        scope = self.create_mock_scope(
+            path="/transfer", query_string=b"amount=10"
+        )
+        scope["session"] = {"user_id": "trusted-user"}
+        receive = AsyncMock(
+            return_value={"type": "http.request", "body": b"", "more_body": False}
+        )
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "http.response.body",
+                "body": b"400 Bad Request: Missing required parameter 'user_id'",
+                "more_body": False,
+            }
+        )
+
+    async def test_request_input_cannot_shadow_explicit_session_read(self):
+        """Extra query fields cannot replace session values read by the handler."""
+
+        async def transfer(self, amount):
+            return f"{self.request.session['user_id']}:{amount}"
+
+        setattr(self.app, "transfer", transfer.__get__(self.app, App))
+        scope = self.create_mock_scope(
+            path="/transfer", query_string=b"amount=10&user_id=attacker"
+        )
+        scope["session"] = {"user_id": "trusted-user"}
+        receive = AsyncMock(
+            return_value={"type": "http.request", "body": b"", "more_body": False}
+        )
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "http.response.body",
+                "body": b"trusted-user:10",
+                "more_body": False,
+            }
+        )
+
     async def test_websocket_argument_binding_uses_cached_metadata(self):
         """WebSocket path and query argument binding should be unchanged."""
 
@@ -470,6 +522,147 @@ class TestFastPaths(MicroPieTestCase):
         send.assert_any_call({"type": "websocket.send", "text": "lobby:alice"})
 
 
+class TestRequestInputParsing(MicroPieTestCase):
+    """Tests for strict URL-encoded query and form parsing."""
+
+    async def test_blank_query_and_form_values_are_preserved(self):
+        """Valid empty values should bind instead of being silently dropped."""
+
+        async def query_value(self, value):
+            return f"query:<{value}>"
+
+        async def form_value(self, value):
+            return f"form:<{value}>"
+
+        setattr(self.app, "query_value", query_value.__get__(self.app, App))
+        setattr(self.app, "form_value", form_value.__get__(self.app, App))
+
+        query_send = AsyncMock()
+        await self.app(
+            self.create_mock_scope(path="/query_value", query_string=b"value="),
+            AsyncMock(
+                return_value={
+                    "type": "http.request",
+                    "body": b"",
+                    "more_body": False,
+                }
+            ),
+            query_send,
+        )
+        query_send.assert_any_call(
+            {
+                "type": "http.response.body",
+                "body": b"query:<>",
+                "more_body": False,
+            }
+        )
+
+        form_send = AsyncMock()
+        await self.app(
+            self.create_mock_scope(
+                path="/form_value",
+                method="POST",
+                headers=[
+                    (b"content-type", b"application/x-www-form-urlencoded")
+                ],
+            ),
+            AsyncMock(
+                return_value={
+                    "type": "http.request",
+                    "body": b"value=",
+                    "more_body": False,
+                }
+            ),
+            form_send,
+        )
+        form_send.assert_any_call(
+            {
+                "type": "http.response.body",
+                "body": b"form:<>",
+                "more_body": False,
+            }
+        )
+
+    async def test_malformed_query_strings_return_400(self):
+        """Malformed fields and percent encoding should be rejected."""
+        for query_string in (b"missing-equals", b"name=%ZZ", b"name=%FF"):
+            with self.subTest(query_string=query_string):
+                send = AsyncMock()
+                await self.app(
+                    self.create_mock_scope(
+                        path="/index", query_string=query_string
+                    ),
+                    AsyncMock(
+                        return_value={
+                            "type": "http.request",
+                            "body": b"",
+                            "more_body": False,
+                        }
+                    ),
+                    send,
+                )
+
+                send.assert_any_call(
+                    {
+                        "type": "http.response.start",
+                        "status": 400,
+                        "headers": [
+                            (b"Content-Type", b"text/html; charset=utf-8")
+                        ],
+                    }
+                )
+                send.assert_any_call(
+                    {
+                        "type": "http.response.body",
+                        "body": b"400 Bad Request: Malformed query string",
+                        "more_body": False,
+                    }
+                )
+
+    async def test_malformed_form_bodies_return_400(self):
+        """Malformed URL-encoded form fields should be rejected."""
+        for body in (b"missing-equals", b"name=%ZZ", b"name=%FF"):
+            with self.subTest(body=body):
+                send = AsyncMock()
+                await self.app(
+                    self.create_mock_scope(
+                        path="/index",
+                        method="POST",
+                        headers=[
+                            (
+                                b"content-type",
+                                b"application/x-www-form-urlencoded",
+                            )
+                        ],
+                    ),
+                    AsyncMock(
+                        return_value={
+                            "type": "http.request",
+                            "body": body,
+                            "more_body": False,
+                        }
+                    ),
+                    send,
+                )
+
+                send.assert_any_call(
+                    {
+                        "type": "http.response.start",
+                        "status": 400,
+                        "headers": [
+                            (b"Content-Type", b"text/html; charset=utf-8")
+                        ],
+                    }
+                )
+                send.assert_any_call(
+                    {
+                        "type": "http.response.body",
+                        "body": b"400 Bad Request: Malformed form body",
+                        "more_body": False,
+                    }
+                )
+
+
 class TestRequestBodyTimeouts(MicroPieTestCase):
     """Tests for configurable non-multipart body receive timeouts."""
 
@@ -1016,6 +1209,48 @@ class TestWebSocket(MicroPieTestCase):
             }
         )
 
+    async def test_session_values_do_not_bind_websocket_parameters(self):
+        """WebSocket arguments must not fall through to session state."""
+
+        async def ws_transfer(self, ws, user_id):
+            await ws.accept()
+
+        setattr(self.app, "ws_transfer", ws_transfer.__get__(self.app, App))
+        scope = self.create_mock_scope(path="/transfer", scope_type="websocket")
+        scope["session"] = {"user_id": "trusted-user"}
+        receive = AsyncMock(return_value={"type": "websocket.connect"})
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "websocket.close",
+                "code": 1008,
+                "reason": "Missing required parameter 'user_id'",
+            }
+        )
+
+    async def test_malformed_websocket_query_is_rejected(self):
+        """WebSocket requests should reject malformed query encoding."""
+        scope = self.create_mock_scope(
+            path="/echo",
+            query_string=b"missing-equals",
+            scope_type="websocket",
+        )
+        receive = AsyncMock(return_value={"type": "websocket.connect"})
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "websocket.close",
+                "code": 1008,
+                "reason": "Malformed query string",
+            }
+        )
+
     async def test_websocket_cookie_respects_secure_configuration(self):
         """WebSocket handshake cookies should use the App cookie policy."""
         self.app = App(session_cookie_secure=False)
@@ -1184,6 +1419,59 @@ class TestResponseHandling(MicroPieTestCase):
                 }
             )
 
+    async def test_json_arguments_keep_native_types_and_form_namespace(self):
+        """JSON binding should not stringify or mirror values into form data."""
+        captured = {}
+
+        async def json_native(self, profile, tags, enabled, count, nullable):
+            captured.update(
+                {
+                    "profile": profile,
+                    "tags": tags,
+                    "enabled": enabled,
+                    "count": count,
+                    "nullable": nullable,
+                    "body_params": self.request.body_params,
+                    "form_profile": self.request.form("profile", "not-form-data"),
+                }
+            )
+            return "OK"
+
+        setattr(self.app, "json_native", json_native.__get__(self.app, App))
+        payload = {
+            "profile": {"id": 7},
+            "tags": ["one", "two"],
+            "enabled": True,
+            "count": 3,
+            "nullable": None,
+        }
+        scope = self.create_mock_scope(
+            path="/json_native",
+            method="POST",
+            headers=[(b"content-type", b"application/json")],
+        )
+        receive = AsyncMock(
+            return_value={
+                "type": "http.request",
+                "body": std_json.dumps(payload).encode("utf-8"),
+                "more_body": False,
+            }
+        )
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        self.assertEqual(captured["profile"], {"id": 7})
+        self.assertEqual(captured["tags"], ["one", "two"])
+        self.assertIs(captured["enabled"], True)
+        self.assertEqual(captured["count"], 3)
+        self.assertIsNone(captured["nullable"])
+        self.assertEqual(captured["body_params"], {})
+        self.assertEqual(captured["form_profile"], "not-form-data")
+        send.assert_any_call(
+            {"type": "http.response.body", "body": b"OK", "more_body": False}
+        )
+
     async def test_invalid_json(self):
         """Test handling of invalid JSON in POST request."""
         scope = self.create_mock_scope(