"""Tests for the personal API tokens feature (app/api/api_tokens.py + auth integration).""" import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.database import Base, get_db from app.models import ApiToken # --------------------------------------------------------------------------- # Test data # --------------------------------------------------------------------------- _OWNER = "tokenuser@example.com" _OTHER_OWNER = "other@example.com" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def tok_engine(): """In-memory SQLite engine.""" engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) yield engine Base.metadata.drop_all(bind=engine) @pytest.fixture() def tok_session(tok_engine): """DB session scoped to one test.""" Session = sessionmaker(bind=tok_engine) session = Session() yield session session.close() def _make_client(tok_engine, owner_id: str = _OWNER) -> TestClient: """Return a TestClient with *owner_id* injected as the authenticated user.""" from app.api.api_tokens import _get_owner_id from app.main import app Session = sessionmaker(bind=tok_engine) def _override_get_db(): session = Session() try: yield session finally: session.close() def _override_owner(): return owner_id app.dependency_overrides[get_db] = _override_get_db app.dependency_overrides[_get_owner_id] = _override_owner client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) return client def _make_unauthenticated_client(tok_engine) -> TestClient: """Return a TestClient that only overrides ``get_db`` (no auth injection). This exercises the real ``_get_owner_id`` → ``get_current_owner_id`` authentication path. Any request made with this client that does not carry a valid session or Bearer token will receive a 401 from the actual auth code, not from a mocked dependency. The caller is responsible for clearing overrides via ``_cleanup(app)`` after the test completes (typically in a ``finally`` block). """ from app.main import app Session = sessionmaker(bind=tok_engine) def _override_get_db(): session = Session() try: yield session finally: session.close() app.dependency_overrides[get_db] = _override_get_db client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) return client def _cleanup(app): """Remove dependency overrides after test.""" app.dependency_overrides.clear() # --------------------------------------------------------------------------- # Tests – Auth Helper # --------------------------------------------------------------------------- class TestGetOwnerId: """Tests for the _get_owner_id dependency helper.""" @pytest.mark.unit def test_get_owner_id_unauthenticated(self): """_get_owner_id should raise a 401 if the user is not authenticated.""" from unittest.mock import MagicMock, patch from fastapi import HTTPException from app.api.api_tokens import _get_owner_id mock_request = MagicMock() with patch("app.api.api_tokens.get_current_owner_id", return_value=None): with pytest.raises(HTTPException) as exc_info: _get_owner_id(mock_request) assert exc_info.value.status_code == 401 assert exc_info.value.detail == "Not authenticated" @pytest.mark.unit def test_get_owner_id_authenticated(self): """_get_owner_id should return owner_id if user is authenticated.""" from unittest.mock import MagicMock, patch from app.api.api_tokens import _get_owner_id mock_request = MagicMock() with patch("app.api.api_tokens.get_current_owner_id", return_value="owner123"): owner_id = _get_owner_id(mock_request) assert owner_id == "owner123" # --------------------------------------------------------------------------- # Tests – Token CRUD # --------------------------------------------------------------------------- class TestTokenCreate: """Tests for POST /api/api-tokens/.""" @pytest.mark.unit def test_create_token_returns_full_token(self, tok_engine): """Creating a token should return the full plaintext token exactly once.""" from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/", json={"name": "Test Token"}) assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}" data = resp.json() assert "token" in data assert data["token"].startswith("de_") assert data["name"] == "Test Token" assert data["is_active"] is True assert data["token_prefix"] == data["token"][:12] finally: _cleanup(app) @pytest.mark.unit def test_create_token_stored_as_hash(self, tok_engine, tok_session): """The database should only store a PBKDF2-HMAC-SHA256 hash, never the plaintext.""" from app.api.api_tokens import hash_token from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/", json={"name": "Hash Check"}) token_plaintext = resp.json()["token"] expected_hash = hash_token(token_plaintext) db_token = tok_session.query(ApiToken).first() assert db_token is not None assert db_token.token_hash == expected_hash finally: _cleanup(app) @pytest.mark.unit def test_create_token_empty_name_rejected(self, tok_engine): """An empty token name should be rejected with 422.""" from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/", json={"name": ""}) assert resp.status_code == 422 finally: _cleanup(app) @pytest.mark.unit def test_create_token_database_error(self, tok_engine, tok_session): """Creating a token should rollback and raise 500 if database commit fails.""" from unittest.mock import patch from sqlalchemy.orm import Session as SASession from app.main import app client = _make_client(tok_engine) try: # Wrap commit: flush first so changes are staged in the transaction, # then raise to simulate a commit failure after data has been written. def _fail_after_flush(self): self.flush() # stage changes inside the open transaction raise Exception("DB Failure") # Spy on rollback so we can assert it is called. rollback_called = False real_rollback = SASession.rollback def _spy_rollback(self): nonlocal rollback_called rollback_called = True real_rollback(self) with patch.object(SASession, "commit", _fail_after_flush): with patch.object(SASession, "rollback", _spy_rollback): resp = client.post("/api/api-tokens/", json={"name": "DB Error Create Test"}) assert resp.status_code == 500 # rollback() must have been called to undo the flushed changes. assert rollback_called, "db.rollback() was not called after commit failure in create_token" # After rollback the token must not exist in the database. db_token = tok_session.query(ApiToken).filter(ApiToken.name == "DB Error Create Test").first() assert db_token is None finally: _cleanup(app) class TestTokenList: """Tests for GET /api/api-tokens/.""" @pytest.mark.unit def test_list_tokens_empty(self, tok_engine): """Listing tokens when none exist should return an empty list.""" from app.main import app client = _make_client(tok_engine) try: resp = client.get("/api/api-tokens/") assert resp.status_code == 200 assert resp.json() == [] finally: _cleanup(app) @pytest.mark.unit def test_list_tokens_returns_multiple(self, tok_engine): """Listing tokens should return all tokens for the current user.""" from app.main import app client = _make_client(tok_engine) try: client.post("/api/api-tokens/", json={"name": "Token A"}) client.post("/api/api-tokens/", json={"name": "Token B"}) resp = client.get("/api/api-tokens/") assert resp.status_code == 200 tokens = resp.json() assert len(tokens) == 2 # Full plaintext should NOT appear in list for t in tokens: assert "token" not in t finally: _cleanup(app) @pytest.mark.unit def test_list_tokens_isolation(self, tok_engine): """Users should only see their own tokens.""" from app.main import app client_a = _make_client(tok_engine, _OWNER) try: client_a.post("/api/api-tokens/", json={"name": "Owner A Token"}) finally: _cleanup(app) client_b = _make_client(tok_engine, _OTHER_OWNER) try: resp = client_b.get("/api/api-tokens/") assert resp.status_code == 200 assert resp.json() == [] finally: _cleanup(app) class TestTokenRevoke: """Tests for DELETE /api/api-tokens/{id}.""" @pytest.mark.unit def test_revoke_token(self, tok_engine): """Revoking a token should set is_active=False.""" from app.main import app client = _make_client(tok_engine) try: create_resp = client.post("/api/api-tokens/", json={"name": "To Revoke"}) token_id = create_resp.json()["id"] resp = client.delete(f"/api/api-tokens/{token_id}") assert resp.status_code == 200 list_resp = client.get("/api/api-tokens/") revoked = [t for t in list_resp.json() if t["id"] == token_id][0] assert revoked["is_active"] is False assert revoked["revoked_at"] is not None finally: _cleanup(app) @pytest.mark.unit def test_delete_already_revoked_token(self, tok_engine): """Deleting an already-revoked token should permanently remove it (hard-delete, 200).""" from app.main import app client = _make_client(tok_engine) try: create_resp = client.post("/api/api-tokens/", json={"name": "Double Revoke"}) token_id = create_resp.json()["id"] client.delete(f"/api/api-tokens/{token_id}") # Second DELETE should hard-delete the revoked token. resp = client.delete(f"/api/api-tokens/{token_id}") assert resp.status_code == 200 assert resp.json()["detail"] == "Token deleted" # Token must no longer appear in the list. list_resp = client.get("/api/api-tokens/") ids = [t["id"] for t in list_resp.json()] assert token_id not in ids finally: _cleanup(app) @pytest.mark.unit def test_revoke_nonexistent_token(self, tok_engine): """Revoking a token that doesn't exist should return 404.""" from app.main import app client = _make_client(tok_engine) try: resp = client.delete("/api/api-tokens/99999") assert resp.status_code == 404 assert resp.json()["detail"] == "Token not found" finally: _cleanup(app) @pytest.mark.unit def test_revoke_token_unauthenticated(self, tok_engine): """Revoking a token without authentication should return 401. Uses a client that only overrides ``get_db`` so that the real ``_get_owner_id`` → ``get_current_owner_id`` path is exercised. Sending no session or Bearer credentials means ``get_current_owner_id`` returns ``None``, and ``_get_owner_id`` raises a 401. """ from app.main import app client = _make_unauthenticated_client(tok_engine) try: resp = client.delete("/api/api-tokens/1") assert resp.status_code == 401 assert resp.json()["detail"] == "Not authenticated" finally: _cleanup(app) @pytest.mark.unit def test_revoke_token_database_error(self, tok_engine, tok_session): """Revoking a token should rollback and raise 500 if database commit fails. The test verifies two properties: 1. ``db.rollback()`` is actually called when commit raises (not just that the endpoint returns 500). 2. After the rollback the token remains active in the database. To ensure the assertions are meaningful, the patched ``commit`` first flushes the session (so the changes *are* staged inside the transaction) before raising. Without a subsequent ``rollback()`` the flushed state would still be visible to other sessions, so the ``is_active`` check would catch a missing rollback call. """ from unittest.mock import patch from sqlalchemy.orm import Session as SASession from app.main import app client = _make_client(tok_engine) try: create_resp = client.post("/api/api-tokens/", json={"name": "DB Error Test"}) token_id = create_resp.json()["id"] # Wrap commit: flush first so changes are staged in the transaction, # then raise to simulate a commit failure after data has been written. def _fail_after_flush(self): self.flush() # stage changes inside the open transaction raise Exception("DB Failure") # Spy on rollback so we can assert it is called. rollback_called = False real_rollback = SASession.rollback def _spy_rollback(self): nonlocal rollback_called rollback_called = True real_rollback(self) with patch.object(SASession, "commit", _fail_after_flush): with patch.object(SASession, "rollback", _spy_rollback): resp = client.delete(f"/api/api-tokens/{token_id}") assert resp.status_code == 500 # rollback() must have been called to undo the flushed changes. assert rollback_called, "db.rollback() was not called after commit failure" # After rollback the token must still be active in the database. db_token = tok_session.query(ApiToken).filter(ApiToken.id == token_id).first() assert db_token.is_active is True finally: _cleanup(app) @pytest.mark.unit def test_revoke_token_invalid_id_format(self, tok_engine): """Revoking a token with a non-integer ID should return 422 Unprocessable Entity.""" from app.main import app client = _make_client(tok_engine) try: resp = client.delete("/api/api-tokens/abc") assert resp.status_code == 422 finally: _cleanup(app) @pytest.mark.unit def test_revoke_other_users_token(self, tok_engine): """A user should not be able to revoke another user's token.""" from app.main import app # Owner A creates a token client_a = _make_client(tok_engine, _OWNER) try: create_resp = client_a.post("/api/api-tokens/", json={"name": "A's Token"}) token_id = create_resp.json()["id"] finally: _cleanup(app) # Owner B tries to revoke it client_b = _make_client(tok_engine, _OTHER_OWNER) try: resp = client_b.delete(f"/api/api-tokens/{token_id}") assert resp.status_code == 404 finally: _cleanup(app) # --------------------------------------------------------------------------- # Tests – Bearer token authentication # --------------------------------------------------------------------------- class TestBearerAuth: """Tests for API token authentication via Authorization: Bearer header.""" @pytest.mark.unit def test_bearer_resolve_user_with_valid_token(self, tok_engine, tok_session): """_resolve_bearer_user should return a user dict for a valid token.""" from unittest.mock import MagicMock # Create a token directly in DB from app.api.api_tokens import generate_api_token, hash_token from app.auth import _resolve_bearer_user plaintext = generate_api_token() token_hash = hash_token(plaintext) db_token = ApiToken( owner_id=_OWNER, name="Test Bearer", token_hash=token_hash, token_prefix=plaintext[:12], is_active=True, ) tok_session.add(db_token) tok_session.commit() # Build a mock request mock_request = MagicMock() mock_request.headers = {"authorization": f"Bearer {plaintext}"} mock_request.client.host = "127.0.0.1" user = _resolve_bearer_user(mock_request, tok_session) assert user is not None assert user["preferred_username"] == _OWNER assert user["_api_token_id"] == db_token.id @pytest.mark.unit def test_bearer_resolve_user_invalid_token(self, tok_engine, tok_session): """_resolve_bearer_user should return None for an invalid token.""" from unittest.mock import MagicMock from app.auth import _resolve_bearer_user mock_request = MagicMock() mock_request.headers = {"authorization": "Bearer de_invalid_token"} mock_request.client.host = "127.0.0.1" user = _resolve_bearer_user(mock_request, tok_session) assert user is None @pytest.mark.unit def test_bearer_resolve_no_header(self, tok_engine, tok_session): """_resolve_bearer_user should return None when no Auth header present.""" from unittest.mock import MagicMock from app.auth import _resolve_bearer_user mock_request = MagicMock() mock_request.headers = {} user = _resolve_bearer_user(mock_request, tok_session) assert user is None @pytest.mark.unit def test_bearer_updates_usage_tracking(self, tok_engine, tok_session): """Using a Bearer token should update last_used_at and last_used_ip.""" from unittest.mock import MagicMock from app.api.api_tokens import generate_api_token, hash_token from app.auth import _resolve_bearer_user plaintext = generate_api_token() token_hash = hash_token(plaintext) db_token = ApiToken( owner_id=_OWNER, name="Usage Track", token_hash=token_hash, token_prefix=plaintext[:12], is_active=True, ) tok_session.add(db_token) tok_session.commit() assert db_token.last_used_at is None assert db_token.last_used_ip is None mock_request = MagicMock() mock_request.headers = { "authorization": f"Bearer {plaintext}", "x-forwarded-for": "203.0.113.42", } mock_request.client.host = "10.0.0.1" _resolve_bearer_user(mock_request, tok_session) tok_session.refresh(db_token) assert db_token.last_used_at is not None assert db_token.last_used_ip == "203.0.113.42" @pytest.mark.unit def test_revoked_token_not_resolved(self, tok_engine, tok_session): """A revoked token should not resolve to a user.""" from unittest.mock import MagicMock from app.api.api_tokens import generate_api_token, hash_token from app.auth import _resolve_bearer_user plaintext = generate_api_token() token_hash = hash_token(plaintext) db_token = ApiToken( owner_id=_OWNER, name="Revoked Token", token_hash=token_hash, token_prefix=plaintext[:12], is_active=False, # Already revoked ) tok_session.add(db_token) tok_session.commit() mock_request = MagicMock() mock_request.headers = {"authorization": f"Bearer {plaintext}"} mock_request.client.host = "127.0.0.1" user = _resolve_bearer_user(mock_request, tok_session) assert user is None # --------------------------------------------------------------------------- # Tests – Token generation utilities # --------------------------------------------------------------------------- class TestTokenUtils: """Tests for token generation and hashing utilities.""" @pytest.mark.unit def test_generate_api_token_format(self): """Generated tokens should start with 'de_' prefix.""" from app.api.api_tokens import generate_api_token token = generate_api_token() assert token.startswith("de_") assert len(token) > 20 # Should be reasonably long @pytest.mark.unit def test_generate_api_token_unique(self): """Each generated token should be unique.""" from app.api.api_tokens import generate_api_token tokens = {generate_api_token() for _ in range(100)} assert len(tokens) == 100 @pytest.mark.unit def test_generate_api_token_length(self): """Generated tokens should have the exact expected length based on TOKEN_BYTES.""" import math from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token # base64url encoding of N bytes without padding: ceil(N * 4 / 3) characters expected_b64_len = math.ceil(TOKEN_BYTES * 4 / 3) expected_total_len = len(TOKEN_PREFIX) + expected_b64_len token = generate_api_token() assert len(token) == expected_total_len @pytest.mark.unit def test_generate_api_token_charset(self): """Generated tokens should only contain URL-safe base64 characters and the prefix.""" import re from app.api.api_tokens import TOKEN_PREFIX, generate_api_token token = generate_api_token() # Check it starts with prefix and the rest is base64url chars ([A-Za-z0-9_-]) pattern = f"^{re.escape(TOKEN_PREFIX)}[A-Za-z0-9_\\-]+$" assert re.match(pattern, token) is not None @pytest.mark.unit def test_generate_api_token_uses_secrets(self): """Generated tokens should use secrets.token_urlsafe with the correct number of bytes.""" from unittest.mock import patch from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token with patch("app.api.api_tokens.secrets.token_urlsafe", return_value="mocked_token") as mock_secrets: token = generate_api_token() mock_secrets.assert_called_once_with(TOKEN_BYTES) assert token == f"{TOKEN_PREFIX}mocked_token" @pytest.mark.unit def test_hash_token_deterministic(self): """Hashing the same token should always produce the same result.""" from app.api.api_tokens import hash_token token = "de_test_token_value" assert hash_token(token) == hash_token(token) @pytest.mark.unit def test_hash_token_output_properties(self): """Token hash should be a 64-character lowercase hex digest.""" from app.api.api_tokens import hash_token token = "de_test_token_value" h = hash_token(token) assert isinstance(h, str) assert len(h) == 64 # All characters should be valid lowercase hex digits. int(h, 16) assert h == h.lower() @pytest.mark.unit def test_hash_token_known_value(self): """hash_token should return the exact expected PBKDF2 digest for a known input.""" from app.api.api_tokens import hash_token # PBKDF2-HMAC-SHA256 with 100,000 iterations and salt b"api-token-v1" token = "de_test_token_value" expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e" assert hash_token(token) == expected_hash # --------------------------------------------------------------------------- # Tests – Token reactivation # --------------------------------------------------------------------------- class TestTokenReactivate: """Tests for POST /api/api-tokens/{id}/reactivate.""" @pytest.mark.unit def test_reactivate_revoked_token(self, tok_engine): """Reactivating a revoked token should set is_active=True and clear revoked_at.""" from app.main import app client = _make_client(tok_engine) try: create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"}) token_id = create_resp.json()["id"] client.delete(f"/api/api-tokens/{token_id}") resp = client.post(f"/api/api-tokens/{token_id}/reactivate") assert resp.status_code == 200 data = resp.json() assert data["is_active"] is True assert data["revoked_at"] is None finally: _cleanup(app) @pytest.mark.unit def test_reactivate_active_token_returns_400(self, tok_engine): """Reactivating an already-active token should return 400.""" from app.main import app client = _make_client(tok_engine) try: create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"}) token_id = create_resp.json()["id"] resp = client.post(f"/api/api-tokens/{token_id}/reactivate") assert resp.status_code == 400 assert resp.json()["detail"] == "Token is already active" finally: _cleanup(app) @pytest.mark.unit def test_reactivate_nonexistent_token(self, tok_engine): """Reactivating a non-existent token should return 404.""" from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/99999/reactivate") assert resp.status_code == 404 finally: _cleanup(app) @pytest.mark.unit def test_reactivate_other_users_token(self, tok_engine): """A user cannot reactivate another user's token.""" from app.main import app client_a = _make_client(tok_engine, _OWNER) try: create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"}) token_id = create_resp.json()["id"] client_a.delete(f"/api/api-tokens/{token_id}") finally: _cleanup(app) client_b = _make_client(tok_engine, _OTHER_OWNER) try: resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate") assert resp.status_code == 404 finally: _cleanup(app) # --------------------------------------------------------------------------- # Tests – Token lifetime (expires_at) # --------------------------------------------------------------------------- class TestTokenExpiry: """Tests for token creation with optional lifetime and expiry enforcement.""" @pytest.mark.unit def test_create_token_without_expiry(self, tok_engine): """Creating a token without expires_in_days should leave expires_at as None.""" from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/", json={"name": "No Expiry"}) assert resp.status_code == 201 data = resp.json() assert data["expires_at"] is None finally: _cleanup(app) @pytest.mark.unit def test_create_token_with_expiry(self, tok_engine, tok_session): """Creating a token with expires_in_days should set expires_at in the future.""" from datetime import datetime, timezone from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30}) assert resp.status_code == 201 data = resp.json() assert data["expires_at"] is not None # Parse the returned datetime; handle both tz-aware and tz-naive serialisations expires_str = data["expires_at"].replace("Z", "+00:00") expires_at = datetime.fromisoformat(expires_str) if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) now = datetime.now(timezone.utc) delta_days = (expires_at - now).days assert 28 <= delta_days <= 30 finally: _cleanup(app) @pytest.mark.unit def test_expired_token_not_resolved(self, tok_engine, tok_session): """A token past its expires_at should not authenticate.""" from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock from app.api.api_tokens import generate_api_token, hash_token from app.auth import _resolve_bearer_user plaintext = generate_api_token() token_hash = hash_token(plaintext) db_token = ApiToken( owner_id=_OWNER, name="Expired Token", token_hash=token_hash, token_prefix=plaintext[:12], is_active=True, expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday ) tok_session.add(db_token) tok_session.commit() mock_request = MagicMock() mock_request.headers = {"authorization": f"Bearer {plaintext}"} mock_request.client.host = "127.0.0.1" user = _resolve_bearer_user(mock_request, tok_session) assert user is None @pytest.mark.unit def test_non_expired_token_resolves(self, tok_engine, tok_session): """A token before its expires_at should authenticate normally.""" from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock from app.api.api_tokens import generate_api_token, hash_token from app.auth import _resolve_bearer_user plaintext = generate_api_token() token_hash = hash_token(plaintext) db_token = ApiToken( owner_id=_OWNER, name="Valid Token", token_hash=token_hash, token_prefix=plaintext[:12], is_active=True, expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days ) tok_session.add(db_token) tok_session.commit() mock_request = MagicMock() mock_request.headers = {"authorization": f"Bearer {plaintext}"} mock_request.client.host = "127.0.0.1" user = _resolve_bearer_user(mock_request, tok_session) assert user is not None assert user["preferred_username"] == _OWNER @pytest.mark.unit def test_create_token_expires_in_days_zero_rejected(self, tok_engine): """expires_in_days=0 should be rejected with 422 (ge=1).""" from app.main import app client = _make_client(tok_engine) try: resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0}) assert resp.status_code == 422 finally: _cleanup(app) @pytest.mark.unit def test_expires_at_included_in_list_response(self, tok_engine): """List endpoint should include expires_at field.""" from app.main import app client = _make_client(tok_engine) try: client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7}) resp = client.get("/api/api-tokens/") assert resp.status_code == 200 tokens = resp.json() assert len(tokens) == 1 assert "expires_at" in tokens[0] assert tokens[0]["expires_at"] is not None finally: _cleanup(app)