Files
copilot-swe-agent[bot] c7d3ec57c3 fix: restore all code deleted/truncated by d2217531 Jules SSRF commit
Commit d2217531 (google-labs-jules SSRF fix) catastrophically deleted
11,500+ lines across 100+ files while fixing an unrelated IMAP issue.

Restored from d2217531^ (pre-bad-commit state):

Deleted files (fully restored):
- app/api/{automation,classification_rules,comments,sharing}.py
- app/middleware/upload_rate_limit.py
- app/tasks/{automation_tasks,classify_document}.py
- app/utils/{automation_hooks,classification_rules}.py
- docs/AppleAppStoreCompliance.md
- frontend/input.css, package.json, package-lock.json, tailwind.config.js
- frontend/static/js/{annotations,claim,comments,sharing}.js
- frontend/templates/{admin_connections,file_annotations,file_summary}.html
- tests/{test_api_files_comprehensive,test_auth_extended,test_sharing,
         test_comments,test_connections,test_imap_profiles,test_api_sessions,
         test_automation,test_classification_rules,test_api_advanced_filters,
         test_api_classification_rules,test_upload_rate_limit,test_api_dropbox,
         test_classify_document,test_comments_ui,test_upload_to_icloud,
         test_api_onedrive_comprehensive,test_frontend_build,test_sentry,
         test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py

Truncated files (content restored):
- app/{auth,config,main,models,celery_worker,database}.py
- app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive,
           integrations,local_auth,mobile,onedrive,pipelines,qr_auth,
           settings,url_upload}.py
- app/middleware/upload_rate_limit.py
- app/tasks/upload_to_nextcloud.py
- app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py
- app/views/{base,dropbox,files,google_drive,onedrive,settings}.py
- docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration,
        DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment,
        MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup,
        SocialLoginSetup,UserGuide}.md
- frontend/static/{js/upload.js,styles.css}
- frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback,
                      file_view,files,google_drive,onedrive,onedrive_callback,
                      signup}.html
- frontend/translations/en.json
- migrations/env.py
- tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings,
         test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks,
         test_setup_wizard,test_views_files_comprehensive}.py

Security fixes kept from post-d2217531 commits:
- app/utils/network.py: DNS SSRF fail-secure fix (06b0fced)
- app/utils/file_operations.py: path traversal fix (1018ea17)
- tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
2026-03-23 23:52:39 +00:00

899 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)