Files
kite/backend/tests/conftest.py
T
curo1305 e186019066 feat(12-02): decompose api/cloud.py into package with connection-ID browse endpoint
- Add api/cloud/ package: connections.py, browse.py, schemas.py, __init__.py
- Add GET /api/cloud/connections/{connection_id}/items (T-12-01 IDOR, T-12-03 cred-free)
- Add PATCH /api/cloud/connections/{id} for display_name_override rename
- Add display_name_override ORM field to CloudConnection model
- Add CloudResourceAdapter service layer with str/UUID coercion
- Fix UUID type compatibility: test_cloud_items.py now uses UUID(as_uuid=True)
  matching conftest — removes String(36) patch that caused type incompatibility
- Add 11 Phase 12 integration tests (IDOR, admin block, credential exclusion,
  duplicate providers, rename, malformed UUID)
- Remove deleted api/cloud.py (replaced by api/cloud/ package)
2026-06-18 23:10:30 +02:00

529 lines
19 KiB
Python

"""
pytest configuration for DocuVault backend tests.
Plan 05 cutover: all sync flat-file fixtures (isolated_data_dir, sync client)
removed. Tests use async fixtures only.
Service availability detection:
- INTEGRATION=1 env var: assume live Docker services are available
- Default (no INTEGRATION): use in-memory SQLite + skip tests requiring real
PostgreSQL/MinIO/Redis
SQLite compatibility note:
The ORM models use PostgreSQL-specific types (UUID, INET, JSONB). SQLite does
not understand these. The db_session fixture patches them before creating
tables so the in-memory engine can build the schema successfully.
"""
from __future__ import annotations
import base64
import os
import socket
import pytest
import pytest_asyncio
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from httpx import ASGITransport, AsyncClient
from sqlalchemy import String, Text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# ── Phase 7.4: test-client user-agent constant ────────────────────────────────
# Tokens issued by test fixtures must embed a fgp claim matching the User-Agent
# the test client will send. httpx.AsyncClient defaults to "python-httpx/X.Y.Z"
# which varies by library version. We lock both token creation and the client
# to this constant so the fgp check in get_current_user always passes in tests.
_TEST_USER_AGENT = "docuvault-test/1.0"
# ── Service availability ──────────────────────────────────────────────────────
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
"""Return True if the given TCP port is reachable."""
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
@pytest.fixture(scope="session")
def live_services_available():
"""True when Docker Compose services are reachable (or INTEGRATION=1 is set)."""
if os.environ.get("INTEGRATION") == "1":
return True
return (
_port_open("localhost", 5432)
and _port_open("localhost", 9000)
and _port_open("localhost", 6379)
)
# ── Core async fixtures ───────────────────────────────────────────────────────
def _patch_pg_types_for_sqlite():
"""Patch PostgreSQL-specific column types so SQLite can create the schema.
SQLite does not know about INET, UUID (as_uuid=True), or JSONB. We
replace them with Text/String equivalents for the in-memory test engine.
This is done by monkey-patching the dialect-type mapping rather than
modifying the models.
"""
try:
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, INET, JSONB
# Override compile methods so SQLite renders them as TEXT
for pg_type in (INET, JSONB):
pg_type.__class_getitem__ = classmethod(lambda cls, item: cls())
# Patch impl so SQLite uses String
if not hasattr(INET, "_sqlite_patched"):
INET.impl = String
INET._sqlite_patched = True
if not hasattr(JSONB, "_sqlite_patched"):
JSONB.impl = Text
JSONB._sqlite_patched = True
except Exception:
pass # If patching fails, the fixture will raise a CompileError naturally
@pytest_asyncio.fixture
async def db_session():
"""In-memory async SQLite session for unit tests.
PostgreSQL-specific column types are overridden to Text/String so that
Base.metadata.create_all works against the SQLite dialect.
"""
from sqlalchemy.dialects.sqlite.base import SQLiteTypeCompiler
from sqlalchemy.dialects.postgresql import INET, JSONB
from db.models import Base
# ── Type compatibility shims ──────────────────────────────────────────────
# PostgreSQL-specific types (INET, JSONB) are unknown to the SQLite dialect.
# Temporarily add visit_* methods that render them as TEXT so that
# Base.metadata.create_all can build the schema in SQLite.
_orig_visit_INET = getattr(SQLiteTypeCompiler, "visit_INET", None)
_orig_visit_JSONB = getattr(SQLiteTypeCompiler, "visit_JSONB", None)
def _visit_inet(self, type_, **kw):
return "TEXT"
def _visit_jsonb(self, type_, **kw):
return "TEXT"
SQLiteTypeCompiler.visit_INET = _visit_inet # type: ignore[attr-defined]
SQLiteTypeCompiler.visit_JSONB = _visit_jsonb # type: ignore[attr-defined]
# UUID(as_uuid=True) renders as CHAR(32) in SQLite — already handled by
# SQLAlchemy's built-in UUID type mapping — no patch needed.
_patched_columns: list = [] # kept for finally-block symmetry
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
AsyncTestSession = async_sessionmaker(engine, expire_on_commit=False)
async with AsyncTestSession() as session:
yield session
finally:
await engine.dispose()
# Restore compiler methods to leave no side effects on other tests
if _orig_visit_INET is not None:
SQLiteTypeCompiler.visit_INET = _orig_visit_INET # type: ignore
else:
try:
del SQLiteTypeCompiler.visit_INET # type: ignore
except AttributeError:
pass
if _orig_visit_JSONB is not None:
SQLiteTypeCompiler.visit_JSONB = _orig_visit_JSONB # type: ignore
else:
try:
del SQLiteTypeCompiler.visit_JSONB # type: ignore
except AttributeError:
pass
# Restore UUID column types to leave no side effects for other test files
for col, orig_type in _patched_columns:
col.type = orig_type
@pytest_asyncio.fixture
async def async_client(db_session: AsyncSession):
"""Async HTTP test client with the DB dependency overridden to use in-memory SQLite.
Phase 7.4: sets a fixed User-Agent (_TEST_USER_AGENT) so that the fgp claim
embedded in tokens from auth_user/admin_user fixtures matches what the client
sends. Without this, httpx's default 'python-httpx/X.Y.Z' UA would differ
from the empty-string UA used when creating tokens without explicit headers.
"""
from deps.db import get_db
from main import app
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"User-Agent": _TEST_USER_AGENT},
) as c:
yield c
app.dependency_overrides.clear()
# ── ES256 test key provisioning — required by all tests after Phase 7.3 ──────
def _generate_es256_test_keys():
private_key = ec.generate_private_key(ec.SECP256R1())
priv_pem = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
pub_pem = private_key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
return base64.b64encode(priv_pem).decode(), base64.b64encode(pub_pem).decode()
_TEST_JWT_PRIVATE_KEY, _TEST_JWT_PUBLIC_KEY = _generate_es256_test_keys()
@pytest.fixture(autouse=True)
def _patch_es256_test_keys(monkeypatch):
"""Provide valid ES256 test keys to every test (Phase 7.3+).
create_access_token and create_password_reset_token require non-empty
jwt_private_key / jwt_public_key after the ES256 upgrade. Without this
fixture, any test that calls those functions fails with ValueError.
Keys are generated once at module load and reused across tests for speed.
"""
import config
monkeypatch.setattr(config.settings, "jwt_private_key", _TEST_JWT_PRIVATE_KEY, raising=False)
monkeypatch.setattr(config.settings, "jwt_public_key", _TEST_JWT_PUBLIC_KEY, raising=False)
# ── Rate limiter reset — prevents cross-test contamination ───────────────────
@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""Reset the in-memory rate limiter storage before each test.
Resets both account_limiter (per-user) and auth_limiter (IP-level) so
that tight test loops against /api/auth/* do not accumulate counters and
cause spurious 429 failures in later tests.
"""
from services.rate_limiting import account_limiter
from api.auth import limiter as auth_limiter
account_limiter._storage.reset()
auth_limiter._storage.reset()
yield
account_limiter._storage.reset()
auth_limiter._storage.reset()
# ── File fixtures ─────────────────────────────────────────────────────────────
@pytest.fixture
def sample_txt(tmp_path):
p = tmp_path / "sample.txt"
p.write_text("This is a test document about invoices and finance.")
return p
@pytest.fixture
def sample_pdf(tmp_path):
"""Create a minimal valid PDF for testing."""
import fitz
doc = fitz.open()
page = doc.new_page()
page.insert_text((50, 50), "Test PDF document about contracts and legal matters.")
pdf_path = tmp_path / "sample.pdf"
doc.save(str(pdf_path))
doc.close()
return pdf_path
# ── Phase 3 shared fixtures ───────────────────────────────────────────────────
# These fixtures are used by test_quota.py, test_documents.py, test_topics.py,
# and test_classifier.py in Plans 03-02 through 03-04.
@pytest_asyncio.fixture
async def auth_user(db_session: AsyncSession):
"""Create a regular user with a Quota row and return auth context.
Returns dict with keys:
- user: User ORM instance
- token: signed JWT access token
- headers: {"Authorization": "Bearer <token>"}
The fixture issues a valid access token via services.auth.create_access_token
so that get_current_user accepts it in downstream endpoint tests.
"""
import uuid as _uuid
from db.models import User, Quota
from services.auth import hash_password, create_access_token
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"testuser_{user_id.hex[:8]}",
email=f"testuser_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(
user_id=user_id,
limit_bytes=104857600, # 100 MB
used_bytes=0,
)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
# Phase 7.4: bind token fgp to _TEST_USER_AGENT so get_current_user validates
# successfully when the token is used via the async_client fixture (which sends
# the same User-Agent constant).
token = create_access_token(str(user_id), "user", user_agent=_TEST_USER_AGENT)
return {
"user": user,
"token": token,
"headers": {"Authorization": f"Bearer {token}"},
}
@pytest_asyncio.fixture
async def second_auth_user(db_session: AsyncSession):
"""Create a second regular user with a Quota row and return auth context.
Returns the same dict shape as auth_user but with a distinct handle prefix
("user2_") so sharing tests can have a sharer and a recipient in the same
test without handle collisions.
"""
import uuid as _uuid
from db.models import User, Quota
from services.auth import hash_password, create_access_token
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"user2_{user_id.hex[:8]}",
email=f"user2_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(
user_id=user_id,
limit_bytes=104857600, # 100 MB
used_bytes=0,
)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
# Phase 7.4: bind token fgp to _TEST_USER_AGENT (matches async_client default)
token = create_access_token(str(user_id), "user", user_agent=_TEST_USER_AGENT)
return {
"user": user,
"token": token,
"headers": {"Authorization": f"Bearer {token}"},
}
@pytest_asyncio.fixture
async def admin_user(db_session: AsyncSession):
"""Create an admin user with a Quota row and return auth context.
Returns the same dict shape as auth_user but with role="admin".
"""
import uuid as _uuid
from db.models import User, Quota
from services.auth import hash_password, create_access_token
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"adminuser_{user_id.hex[:8]}",
email=f"adminuser_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="admin",
is_active=True,
password_must_change=False,
)
quota = Quota(
user_id=user_id,
limit_bytes=104857600,
used_bytes=0,
)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
# Phase 7.4: bind token fgp to _TEST_USER_AGENT (matches async_client default)
token = create_access_token(str(user_id), "admin", user_agent=_TEST_USER_AGENT)
return {
"user": user,
"token": token,
"headers": {"Authorization": f"Bearer {token}"},
}
@pytest.fixture
def mock_minio_presigned(monkeypatch):
"""Patch MinIOBackend.generate_presigned_put_url with an AsyncMock.
The patched method does not exist yet — it is added in Plan 03-02.
Using raising=False ensures the patch installs before the attribute exists.
Yields the AsyncMock so tests can assert call counts and args.
"""
from unittest.mock import AsyncMock
mock = AsyncMock(return_value="http://localhost:9000/docuvault/test-presigned-url")
try:
from storage.minio_backend import MinIOBackend
monkeypatch.setattr(MinIOBackend, "generate_presigned_put_url", mock, raising=False)
except ImportError:
pass # storage module not yet available — patch is best-effort
yield mock
@pytest.fixture
def mock_minio_stat(monkeypatch):
"""Patch MinIOBackend.stat_object with an AsyncMock returning 1024 bytes.
The patched method does not exist yet — it is added in Plan 03-02.
Using raising=False ensures the patch installs before the attribute exists.
Yields the AsyncMock for per-test customization:
mock_minio_stat.return_value = 50_000_000
"""
from unittest.mock import AsyncMock
mock = AsyncMock(return_value=1024)
try:
from storage.minio_backend import MinIOBackend
monkeypatch.setattr(MinIOBackend, "stat_object", mock, raising=False)
except ImportError:
pass # storage module not yet available — patch is best-effort
yield mock
# ── Phase 5 cloud storage fixtures ───────────────────────────────────────────
@pytest.fixture
def mock_google_drive_creds():
"""Return a fake Google Drive OAuth credential dict for unit tests.
Shape mirrors what google-auth-oauthlib stores in credentials.to_json().
Expiry is far in the future so tests never hit a token-refresh branch.
"""
return {
"access_token": "ya29.test_access",
"refresh_token": "1//test_refresh",
"expiry": "2099-12-31T23:59:59",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "test_client_id",
"client_secret": "test_client_secret",
}
@pytest.fixture
def mock_onedrive_creds():
"""Return a fake OneDrive MSAL credential dict for unit tests.
Shape mirrors what msal.PublicClientApplication.acquire_token_by_auth_code_flow
returns (simplified subset required by cloud_utils).
"""
return {
"access_token": "test_ms_access",
"refresh_token": "test_ms_refresh",
"expires_at": "2099-12-31T23:59:59",
}
@pytest.fixture
def mock_webdav_client():
"""Return a MagicMock simulating a webdavclient3 Client instance.
All four methods the DocuVault cloud layer calls are pre-wired to return None
so tests can assert call counts without making real network connections.
"""
from unittest.mock import MagicMock
client = MagicMock()
client.upload_to = MagicMock(return_value=None)
client.download_from = MagicMock(return_value=None)
client.list = MagicMock(return_value=None)
client.check = MagicMock(return_value=None)
return client
@pytest_asyncio.fixture
async def cloud_connection_factory(db_session: AsyncSession):
"""Factory fixture that creates CloudConnection ORM rows in the test db_session.
Usage::
async def test_something(cloud_connection_factory, auth_user):
conn = await cloud_connection_factory(
db_session, auth_user["user"].id, provider="google_drive"
)
assert conn.status == "ACTIVE"
Parameters
----------
session : AsyncSession
The async SQLAlchemy session to use (typically db_session).
user_id : uuid.UUID | str
Owner of the cloud connection.
provider : str
Provider slug, e.g. "google_drive", "onedrive", "webdav".
status : str
Connection status string, default "ACTIVE".
display_name : str | None
Human-readable label; defaults to "<provider> account".
credentials_enc : str
Encrypted credential blob (use a placeholder in unit tests).
"""
import uuid as _uuid
from db.models import CloudConnection
async def _factory(
session: AsyncSession,
user_id,
provider: str = "google_drive",
status: str = "ACTIVE",
display_name: str | None = None,
credentials_enc: str = "fake_encrypted_creds",
) -> CloudConnection:
conn = CloudConnection(
id=_uuid.uuid4(),
user_id=user_id if isinstance(user_id, _uuid.UUID) else _uuid.UUID(str(user_id)),
provider=provider,
display_name=display_name or f"{provider} account",
credentials_enc=credentials_enc,
status=status,
)
session.add(conn)
await session.flush()
return conn
return _factory