fix(api-tokens): preserve scopes on a partial token update (#3407)

PATCH /api/tokens/{id} unconditionally recomputed scopes from
payload.get("scopes"). On a rename — body {"name": "..."} with no "scopes"
key — that is None, so _normalize_scopes(None) returned the default ["chat"]
and the handler overwrote token.scopes, silently dropping every scope the
token had been granted (e.g. email:read, calendar:write).

Only write scopes when the request actually includes them, and return the
token's real stored scopes in the response (matching the GET /tokens display
shape) instead of the recomputed default.

tests/test_api_token_routes.py: add rename-preserves-scopes,
explicit-scopes-applied, and missing-token-404 cases for the PATCH handler.
This commit is contained in:
Mazen Tamer Salah
2026-06-08 20:37:31 +03:00
committed by GitHub
parent d58202d10e
commit 8449baea80
2 changed files with 94 additions and 4 deletions
+82
View File
@@ -5,6 +5,7 @@ Uses direct endpoint extraction from setup_api_token_routes().routes and
fake objects only — no real DB, no network, no external services.
"""
import asyncio
import contextlib
import datetime
import secrets as _secrets_mod
@@ -292,3 +293,84 @@ def test_delete_missing_token_returns_404_without_invalidating_cache(monkeypatch
delete_token(request=req, token_id="missing99")
assert exc.value.status_code == 404
invalidator.assert_not_called()
# ---------------------------------------------------------------------------
# 6. PATCH /api/tokens/{id} — a partial update must not wipe scopes
# ---------------------------------------------------------------------------
def _patch_request(invalidator, body):
"""An admin request whose async .json() yields `body`."""
req = _req("alice", is_admin=True, invalidator=invalidator)
async def _json():
return body
req.json = _json
return req
def test_update_token_rename_preserves_scopes(monkeypatch, token_routes_mod):
"""Renaming a token (no 'scopes' key in the body) must keep its scopes.
Previously update_token recomputed scopes from payload.get("scopes"),
which is None on a rename, so _normalize_scopes(None) reset every token to
the default ["chat"] scope — a silent privilege/data loss.
"""
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
token = SimpleNamespace(
id="tok123", name="original", owner="alice",
token_prefix="ody_orig", scopes="email:read,email:draft", is_active=True,
)
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
invalidator = MagicMock()
req = _patch_request(invalidator, {"name": "renamed"})
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
resp = asyncio.run(update_token(request=req, token_id="tok123"))
assert token.scopes == "email:read,email:draft" # untouched
assert resp["scopes"] == ["email:read", "email:draft"]
assert token.name == "renamed"
invalidator.assert_called_once()
def test_update_token_applies_explicit_scopes(monkeypatch, token_routes_mod):
"""When the body includes 'scopes', they are normalized and written."""
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
token = SimpleNamespace(
id="tok123", name="original", owner="alice",
token_prefix="ody_orig", scopes="email:read,email:draft", is_active=True,
)
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _patch_request(MagicMock(), {"scopes": ["chat"]})
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
resp = asyncio.run(update_token(request=req, token_id="tok123"))
assert token.scopes == "chat"
assert resp["scopes"] == ["chat"]
def test_update_missing_token_returns_404(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = None
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _patch_request(MagicMock(), {"name": "x"})
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(update_token(request=req, token_id="missing99"))
assert exc.value.status_code == 404