Skip to content

Commit cc41a43

Browse files
authored
fix(api): normalize non-object JSON bodies to empty dict in token PATCH (odysseus-dev#3976)
* fix(api): normalize non-object JSON bodies to empty dict in token PATCH Valid non-dict JSON (e.g. [] or null) reaches payload.get(...) and raises AttributeError. Normalize to {} so the route returns a controlled response instead of an unhandled 500. Fixes odysseus-dev#3966 * test(api): add regression tests for PATCH with non-object JSON bodies Covers array body ([]), null body, and normal object body as requested in alteixeira20's review of odysseus-dev#3976. --------- Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
1 parent 2e76f87 commit cc41a43

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

routes/api_token_routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ async def update_token(request: Request, token_id: str):
160160
payload = await request.json()
161161
except Exception:
162162
payload = {}
163+
if not isinstance(payload, dict):
164+
payload = {}
163165
with get_db_session() as db:
164166
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
165167
if not token:

tests/test_api_token_routes.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,77 @@ def test_delete_token_owner_check_skipped_when_auth_disabled(monkeypatch, token_
502502
resp = delete_token(request=req, token_id="tok123")
503503
assert resp == {"status": "deleted"}
504504
fake_session.delete.assert_called_once_with(fake_token)
505+
506+
507+
# ---------------------------------------------------------------------------
508+
# 7. PATCH /api/tokens/{id} — non-object JSON bodies must not 500
509+
# ---------------------------------------------------------------------------
510+
511+
512+
def test_update_token_with_array_body_does_not_500(monkeypatch, token_routes_mod):
513+
"""PATCH body of [] must be normalised to {} and not raise."""
514+
monkeypatch.setenv("AUTH_ENABLED", "true")
515+
mod = token_routes_mod
516+
517+
token = SimpleNamespace(
518+
id="tok123", name="original", owner="alice",
519+
token_prefix="ody_orig", scopes="email:read", is_active=True,
520+
)
521+
fake_session = MagicMock()
522+
fake_session.query.return_value.filter.return_value.first.return_value = token
523+
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
524+
525+
invalidator = MagicMock()
526+
req = _patch_request(invalidator, [])
527+
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
528+
resp = asyncio.run(update_token(request=req, token_id="tok123"))
529+
530+
# Name and scopes must be unchanged — payload was normalised to {}
531+
assert token.name == "original"
532+
assert token.scopes == "email:read"
533+
assert resp["name"] == "original"
534+
535+
536+
def test_update_token_with_null_body_does_not_500(monkeypatch, token_routes_mod):
537+
"""PATCH body of null must be normalised to {} and not raise."""
538+
monkeypatch.setenv("AUTH_ENABLED", "true")
539+
mod = token_routes_mod
540+
541+
token = SimpleNamespace(
542+
id="tok123", name="original", owner="alice",
543+
token_prefix="ody_orig", scopes="chat", is_active=True,
544+
)
545+
fake_session = MagicMock()
546+
fake_session.query.return_value.filter.return_value.first.return_value = token
547+
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
548+
549+
invalidator = MagicMock()
550+
req = _patch_request(invalidator, None)
551+
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
552+
resp = asyncio.run(update_token(request=req, token_id="tok123"))
553+
554+
assert token.name == "original"
555+
assert token.scopes == "chat"
556+
557+
558+
def test_update_token_normal_object_still_works(monkeypatch, token_routes_mod):
559+
"""Normal dict payload continues to update fields as before."""
560+
monkeypatch.setenv("AUTH_ENABLED", "true")
561+
mod = token_routes_mod
562+
563+
token = SimpleNamespace(
564+
id="tok123", name="original", owner="alice",
565+
token_prefix="ody_orig", scopes="email:read", is_active=True,
566+
)
567+
fake_session = MagicMock()
568+
fake_session.query.return_value.filter.return_value.first.return_value = token
569+
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
570+
571+
invalidator = MagicMock()
572+
req = _patch_request(invalidator, {"name": "updated"})
573+
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
574+
resp = asyncio.run(update_token(request=req, token_id="tok123"))
575+
576+
assert token.name == "updated"
577+
assert resp["name"] == "updated"
578+
invalidator.assert_called_once()

0 commit comments

Comments
 (0)