Files
DocsGPT/tests/api/test_pat_routes.py
arc53-machine 91f2ec2f09 feat(pat): regenerate a token's secret and reset its expiry
POST /api/user/tokens/<id>/regenerate swaps the secret of an existing token
in place: name, scopes and restrictions stay, the old secret stops matching
at once, and the expiry is reset. The lifetime defaults to the one the token
was last issued with (clamped to today's policy) or to expires_in_days when
given. An expired token can be renewed this way; a revoked one cannot. It is
session only like the rest of token management, and writes a pat_regenerated
audit event. regenerated_at records the rotation.
2026-09-21 10:32:17 +01:00

394 lines
18 KiB
Python

"""Endpoint tests for personal access token management (/api/user/tokens, admin)."""
from __future__ import annotations
import json
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from sqlalchemy import text
from docsgpt.api.pat import routes as pat_routes
from docsgpt.api.pat import tokens as pat_tokens
AGENT_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
@pytest.fixture
def client():
from docsgpt.app import app
app.config["TESTING"] = True
return app.test_client()
@pytest.fixture(autouse=True)
def _policy(monkeypatch):
monkeypatch.setattr(pat_tokens.settings, "AUTH_TYPE", "oidc")
monkeypatch.setattr(pat_tokens.settings, "PAT_ENABLED", True)
monkeypatch.setattr(pat_tokens.settings, "PAT_DEFAULT_LIFETIME_DAYS", 90)
monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_LIFETIME_DAYS", 365)
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", False)
monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_PER_USER", 25)
@pytest.fixture
def db(pg_conn):
"""Route every session the token code opens onto the test's rolled-back connection."""
@contextmanager
def _yield_conn():
yield pg_conn
with patch.object(pat_routes, "db_session", _yield_conn), patch.object(
pat_routes, "db_readonly", _yield_conn
), patch.object(pat_tokens, "db_session", _yield_conn), patch.object(
pat_tokens, "db_readonly", _yield_conn
):
yield pg_conn
@contextmanager
def _session(sub="alice", roles=("user",)):
with patch("docsgpt.app.handle_auth", return_value={"sub": sub}), patch(
"docsgpt.app.resolve_roles", return_value=list(roles)
), patch("docsgpt.app.oidc_session_denied", return_value=False):
yield
def _create(client, **body):
body.setdefault("name", "ci")
body.setdefault("scopes", ["agents:write"])
with _session():
return client.post("/api/user/tokens", json=body)
class TestCreate:
def test_returns_the_secret_once_and_stores_only_its_hash(self, client, db):
response = _create(client)
assert response.status_code == 201
body = json.loads(response.data)
token = body["token"]
assert token.startswith("dgpt_pat_")
public = body["personal_access_token"]
assert public["token_prefix"] == token[:15]
assert "token" not in public and "token_hash" not in public
stored = db.execute(text("SELECT token_hash FROM personal_access_tokens")).scalar_one()
assert stored == pat_tokens.hash_token(token)
assert token not in stored
with _session():
listed = json.loads(client.get("/api/user/tokens").data)
assert [t["name"] for t in listed["tokens"]] == ["ci"]
assert token not in json.dumps(listed)
def test_default_expiry_applies(self, client, db):
body = json.loads(_create(client).data)
assert body["personal_access_token"]["expires_at"] is not None
def test_non_expiring_needs_operator_opt_in(self, client, db, monkeypatch):
assert _create(client, expires_in_days=0).status_code == 400
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", True)
response = _create(client, expires_in_days=0)
assert response.status_code == 201
assert json.loads(response.data)["personal_access_token"]["expires_at"] is None
def test_lifetime_cap(self, client, db):
assert _create(client, expires_in_days=366).status_code == 400
def test_resource_filter_is_stored(self, client, db):
response = _create(client, resource_filter={"agents": [AGENT_A]})
assert json.loads(response.data)["personal_access_token"]["resource_filter"] == {
"agents": [AGENT_A]
}
@pytest.mark.parametrize(
"body",
[
{"name": ""},
{"name": "x" * 101},
{"scopes": []},
{"scopes": ["admin:all"]},
{"resource_filter": {"agents": ["nope"]}},
{"resource_filter": {"sources": [AGENT_A]}},
{"expires_in_days": "soon"},
],
)
def test_validation(self, client, db, body):
assert _create(client, **body).status_code == 400
@pytest.mark.parametrize("body", [[1], "text", 5])
def test_non_object_body_is_a_client_error(self, client, db, body):
with _session():
assert client.post("/api/user/tokens", json=body).status_code == 400
def test_duplicate_name_conflicts(self, client, db):
assert _create(client).status_code == 201
assert _create(client).status_code == 409
def test_expired_token_does_not_reserve_its_name(self, client, db):
assert _create(client).status_code == 201
db.execute(text("UPDATE personal_access_tokens SET expires_at = now() - interval '1 day'"))
assert _create(client).status_code == 201
rows = db.execute(
text("SELECT status, revoke_reason FROM personal_access_tokens ORDER BY created_at")
).all()
assert [tuple(r) for r in rows] == [("revoked", "expired"), ("active", None)]
def test_per_user_cap(self, client, db, monkeypatch):
monkeypatch.setattr(pat_tokens.settings, "PAT_MAX_PER_USER", 1)
assert _create(client, name="one").status_code == 201
assert _create(client, name="two").status_code == 409
@pytest.mark.parametrize("auth_type", ["simple_jwt", "session_jwt"])
def test_unavailable_without_a_stable_identity(self, client, db, monkeypatch, auth_type):
monkeypatch.setattr(pat_tokens.settings, "AUTH_TYPE", auth_type)
assert _create(client).status_code == 403
def test_disabled_by_operator(self, client, db, monkeypatch):
monkeypatch.setattr(pat_tokens.settings, "PAT_ENABLED", False)
assert _create(client).status_code == 403
with _session():
assert json.loads(client.get("/api/user/tokens").data)["policy"]["enabled"] is False
def test_requires_a_session(self, client, db):
with patch("docsgpt.app.handle_auth", return_value=None):
assert client.post("/api/user/tokens", json={"name": "x", "scopes": ["agents:read"]}).status_code == 401
def test_audited(self, client, db):
_create(client)
event, metadata = db.execute(
text("SELECT event, metadata FROM auth_events WHERE user_id = 'alice'")
).one()
assert event == "pat_created"
assert metadata["scopes"] == ["agents:write"]
assert "dgpt_pat_" not in json.dumps(metadata)
class TestExpiredStatus:
def test_expired_token_is_reported_as_expired_not_active(self, client, db):
_create(client)
with _session():
assert json.loads(client.get("/api/user/tokens").data)["tokens"][0]["status"] == "active"
db.execute(text("UPDATE personal_access_tokens SET expires_at = now() - interval '1 day'"))
with _session():
assert json.loads(client.get("/api/user/tokens").data)["tokens"][0]["status"] == "expired"
class TestList:
def test_includes_scope_catalog_and_policy(self, client, db):
with _session():
body = json.loads(client.get("/api/user/tokens").data)
assert {s["name"] for s in body["scopes"]} == set(pat_tokens.SCOPES)
assert body["policy"] == {
"enabled": True,
"default_lifetime_days": 90,
"max_lifetime_days": 365,
"allow_non_expiring": False,
"max_per_user": 25,
"filterable_families": list(pat_tokens.FILTERABLE_FAMILIES),
}
def test_is_owner_scoped(self, client, db):
_create(client)
with _session(sub="bob"):
assert json.loads(client.get("/api/user/tokens").data)["tokens"] == []
class TestEndToEnd:
def test_created_token_authenticates_and_revocation_is_immediate(self, client, db):
created = json.loads(_create(client, scopes=["prompts:read"]).data)
headers = {"Authorization": f"Bearer {created['token']}"}
me = client.get("/api/user/me", headers=headers)
assert me.status_code == 200
body = json.loads(me.data)
assert body["user_id"] == "alice"
assert body["token"]["scopes"] == ["prompts:read"]
# Scoped: cannot list agents, cannot manage tokens.
assert client.get("/api/get_agents", headers=headers).status_code == 403
assert client.get("/api/user/tokens", headers=headers).status_code == 403
assert client.post("/api/user/tokens", headers=headers, json={}).status_code == 403
with _session():
token_id = created["personal_access_token"]["id"]
assert client.delete(f"/api/user/tokens/{token_id}").status_code == 200
assert client.get("/api/user/me", headers=headers).status_code == 401
def test_tampered_token_is_rejected(self, client, db):
token = json.loads(_create(client).data)["token"]
response = client.get("/api/user/me", headers={"Authorization": f"Bearer {token}x"})
assert response.status_code == 401
class TestRegenerate:
def _regen(self, client, token_id, **body):
with _session():
return client.post(f"/api/user/tokens/{token_id}/regenerate", json=body)
def test_new_secret_works_old_one_stops_and_the_rest_is_kept(self, client, db):
created = json.loads(
_create(client, scopes=["prompts:read"], resource_filter={"prompts": [AGENT_A]}).data
)
old, token_id = created["token"], created["personal_access_token"]["id"]
response = self._regen(client, token_id)
assert response.status_code == 200
body = json.loads(response.data)
new, public = body["token"], body["personal_access_token"]
assert new.startswith("dgpt_pat_") and new != old
assert public["id"] == token_id and public["name"] == "ci"
assert public["scopes"] == ["prompts:read"]
assert public["resource_filter"] == {"prompts": [AGENT_A]}
assert public["token_prefix"] == new[:15]
assert public["regenerated_at"] is not None
assert public["last_used_at"] is None
assert client.get("/api/user/me", headers={"Authorization": f"Bearer {old}"}).status_code == 401
me = client.get("/api/user/me", headers={"Authorization": f"Bearer {new}"})
assert me.status_code == 200
assert json.loads(me.data)["token"]["id"] == token_id
stored = db.execute(text("SELECT token_hash FROM personal_access_tokens")).scalar_one()
assert stored == pat_tokens.hash_token(new)
def test_expiry_is_reset_to_the_original_lifetime(self, client, db):
token_id = json.loads(_create(client, expires_in_days=30).data)["personal_access_token"]["id"]
# 20 days in: 10 days left.
db.execute(
text(
"UPDATE personal_access_tokens SET created_at = now() - interval '20 days', "
"expires_at = now() + interval '10 days'"
)
)
self._regen(client, token_id)
days_left = db.execute(
text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens")
).scalar_one()
assert 29.9 < float(days_left) < 30.1
def test_explicit_lifetime_is_honoured_and_capped(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
assert self._regen(client, token_id, expires_in_days=7).status_code == 200
days_left = db.execute(
text("SELECT extract(epoch FROM expires_at - now()) / 86400 FROM personal_access_tokens")
).scalar_one()
assert 6.9 < float(days_left) < 7.1
assert self._regen(client, token_id, expires_in_days=366).status_code == 400
assert self._regen(client, token_id, expires_in_days=0).status_code == 400
def test_an_expired_token_can_be_renewed(self, client, db):
created = json.loads(_create(client, expires_in_days=30).data)
db.execute(
text(
"UPDATE personal_access_tokens SET created_at = now() - interval '31 days', "
"expires_at = now() - interval '1 day'"
)
)
old_headers = {"Authorization": f"Bearer {created['token']}"}
assert client.get("/api/user/me", headers=old_headers).status_code == 401
body = json.loads(self._regen(client, created["personal_access_token"]["id"]).data)
assert body["personal_access_token"]["status"] == "active"
headers = {"Authorization": f"Bearer {body['token']}"}
assert client.get("/api/user/me", headers=headers).status_code == 200
def test_non_expiring_token_stays_non_expiring_only_while_the_operator_allows_it(
self, client, db, monkeypatch
):
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", True)
token_id = json.loads(_create(client, expires_in_days=0).data)["personal_access_token"]["id"]
assert json.loads(self._regen(client, token_id).data)["personal_access_token"]["expires_at"] is None
monkeypatch.setattr(pat_tokens.settings, "PAT_ALLOW_NON_EXPIRING", False)
renewed = json.loads(self._regen(client, token_id).data)["personal_access_token"]
assert renewed["expires_at"] is not None # falls back to the default lifetime
def test_revoked_foreign_unknown_and_malformed_tokens_are_not_found(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
with _session(sub="bob"):
assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 404
with _session():
client.delete(f"/api/user/tokens/{token_id}")
assert self._regen(client, token_id).status_code == 404
assert self._regen(client, AGENT_A).status_code == 404
assert self._regen(client, f"urn:uuid:{AGENT_A}").status_code == 404
def test_a_token_cannot_regenerate_itself_or_others(self, client, db):
created = json.loads(_create(client, scopes=list(pat_tokens.SCOPES)).data)
headers = {"Authorization": f"Bearer {created['token']}"}
token_id = created["personal_access_token"]["id"]
response = client.post(f"/api/user/tokens/{token_id}/regenerate", headers=headers)
assert response.status_code == 403
assert json.loads(response.data)["error"] == "not_available_to_tokens"
def test_requires_a_session_and_an_object_body(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
with patch("docsgpt.app.handle_auth", return_value=None):
assert client.post(f"/api/user/tokens/{token_id}/regenerate").status_code == 401
with _session():
assert client.post(f"/api/user/tokens/{token_id}/regenerate", json=[1]).status_code == 400
def test_audited_without_the_secret(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
self._regen(client, token_id)
metadata = db.execute(
text("SELECT metadata FROM auth_events WHERE event = 'pat_regenerated'")
).scalar_one()
assert metadata["token_id"] == token_id
assert "dgpt_pat_" not in json.dumps(metadata)
class TestRevoke:
def test_cannot_revoke_someone_elses_token(self, client, db):
token_id = json.loads(_create(client).data)["personal_access_token"]["id"]
with _session(sub="bob"):
assert client.delete(f"/api/user/tokens/{token_id}").status_code == 404
@pytest.mark.parametrize(
"token_id",
[AGENT_A, "not-a-uuid", f"urn:uuid:{AGENT_A}", "{" + AGENT_A + "}", AGENT_A.replace("-", "")],
)
def test_unknown_and_malformed_ids(self, client, db, token_id):
# uuid.UUID() accepts urn:/braced/bare-hex spellings that Postgres rejects; none may reach the cast.
with _session():
assert client.delete(f"/api/user/tokens/{token_id}").status_code == 404
with _session(sub="root", roles=("admin", "user")):
assert client.delete(f"/api/admin/tokens/{token_id}").status_code == 404
class TestAdmin:
def test_requires_admin(self, client, db):
with _session():
assert client.get("/api/admin/users/alice/tokens").status_code == 403
assert client.delete(f"/api/admin/tokens/{AGENT_A}").status_code == 403
def test_admin_can_list_and_revoke_any_token(self, client, db):
created = json.loads(_create(client).data)
token_id = created["personal_access_token"]["id"]
with _session(sub="root", roles=("admin", "user")):
listed = json.loads(client.get("/api/admin/users/alice/tokens").data)
assert [t["id"] for t in listed["tokens"]] == [token_id]
assert client.delete(f"/api/admin/tokens/{token_id}").status_code == 200
assert client.delete(f"/api/admin/tokens/{token_id}").status_code == 404
headers = {"Authorization": f"Bearer {created['token']}"}
assert client.get("/api/user/me", headers=headers).status_code == 401
def test_revoke_sessions_also_revokes_tokens(self, client, db):
from docsgpt.api.admin import routes as admin_routes
@contextmanager
def _yield_conn():
yield db
created = json.loads(_create(client).data)
with _session(sub="root", roles=("admin", "user")), patch.object(
admin_routes, "db_session", _yield_conn
), patch.object(admin_routes.denylist, "deny_user", return_value=True):
assert client.post("/api/admin/users/alice/revoke-sessions").status_code == 200
headers = {"Authorization": f"Bearer {created['token']}"}
assert client.get("/api/user/me", headers=headers).status_code == 401
events = db.execute(
text("SELECT metadata FROM auth_events WHERE user_id = 'alice' AND event = 'pat_revoked'")
).all()
assert [e[0]["token_id"] for e in events] == [created["personal_access_token"]["id"]]
assert events[0][0]["via"] == "admin_sessions_revoked"