"""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 _cleanup(app): """Remove dependency overrides after test.""" app.dependency_overrides.clear() # --------------------------------------------------------------------------- # 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) 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_revoke_already_revoked_token(self, tok_engine): """Revoking an already-revoked token should return 400.""" 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}") resp = client.delete(f"/api/api-tokens/{token_id}") assert resp.status_code == 400 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 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_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()