ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
242 lines
7.7 KiB
Python
242 lines
7.7 KiB
Python
"""Shared test fixtures for the CMP API test suite.
|
|
|
|
Provides two modes:
|
|
- Unit tests: use `app` and `client` fixtures (no database required)
|
|
- Integration tests: use `db_client` fixture (requires PostgreSQL)
|
|
|
|
Integration tests are automatically skipped when no database is available.
|
|
"""
|
|
|
|
import os
|
|
|
|
# Disable rate limiting for the test suite. Many tests make dozens of
|
|
# requests from the same loopback address in rapid succession and the
|
|
# middleware would legitimately reject them as a DoS; the middleware
|
|
# has its own dedicated test module.
|
|
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
|
|
os.environ.setdefault("ENVIRONMENT", "test")
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
|
|
from src.main import create_app
|
|
from src.models.base import Base
|
|
|
|
# ── Detect whether a test database is available ──────────────────────
|
|
|
|
_TEST_DB_URL = os.environ.get(
|
|
"TEST_DATABASE_URL",
|
|
os.environ.get("DATABASE_URL", ""),
|
|
)
|
|
|
|
_HAS_DB = bool(_TEST_DB_URL) and "localhost" in _TEST_DB_URL
|
|
|
|
|
|
def _requires_db(fn):
|
|
"""Mark a test as requiring a live database.
|
|
|
|
Also pins the event loop to session scope so that fixtures sharing the
|
|
session-scoped engine don't get 'Future attached to a different loop'.
|
|
"""
|
|
fn = pytest.mark.asyncio(loop_scope="session")(fn)
|
|
fn = pytest.mark.skipif(not _HAS_DB, reason="No test database available")(fn)
|
|
return fn
|
|
|
|
|
|
requires_db = _requires_db
|
|
|
|
|
|
# ── Unit test fixtures (no database) ─────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""Create a fresh FastAPI application instance."""
|
|
return create_app()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app):
|
|
"""Async HTTP client for unit tests (no database)."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
|
|
# ── Integration test fixtures (with database) ────────────────────────
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def _test_engine():
|
|
"""Create a test database engine (session-scoped)."""
|
|
if not _HAS_DB:
|
|
pytest.skip("No test database available")
|
|
return create_async_engine(_TEST_DB_URL, echo=False)
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
|
async def _setup_db(_test_engine):
|
|
"""Create all tables once per test session, then seed fixture data.
|
|
|
|
Tests that depend on the cookie-category seed (normally applied by
|
|
the ``0001_initial_schema`` alembic migration) get the same rows
|
|
here so they can run without invoking alembic.
|
|
"""
|
|
async with _test_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await _seed_cookie_categories(conn)
|
|
yield
|
|
async with _test_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
|
|
async def _seed_cookie_categories(conn) -> None:
|
|
"""Insert the default cookie categories. Mirrors migration 0001."""
|
|
import uuid as _uuid
|
|
|
|
from sqlalchemy import text
|
|
|
|
rows = [
|
|
("10000000-0000-0000-0000-000000000001", "Necessary", "necessary", True, 0),
|
|
("10000000-0000-0000-0000-000000000002", "Functional", "functional", False, 1),
|
|
("10000000-0000-0000-0000-000000000003", "Analytics", "analytics", False, 2),
|
|
("10000000-0000-0000-0000-000000000004", "Marketing", "marketing", False, 3),
|
|
("10000000-0000-0000-0000-000000000005", "Personalisation", "personalisation", False, 4),
|
|
]
|
|
stmt = text(
|
|
"""
|
|
INSERT INTO cookie_categories
|
|
(id, name, slug, description, is_essential, display_order)
|
|
VALUES (:id, :name, :slug, :description, :is_essential, :display_order)
|
|
ON CONFLICT (slug) DO NOTHING
|
|
""",
|
|
)
|
|
for row_id, name, slug, is_essential, order in rows:
|
|
await conn.execute(
|
|
stmt,
|
|
{
|
|
"id": _uuid.UUID(row_id),
|
|
"name": name,
|
|
"slug": slug,
|
|
"description": f"{name} cookies",
|
|
"is_essential": is_essential,
|
|
"display_order": order,
|
|
},
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="session")
|
|
async def db_client(_test_engine, _setup_db):
|
|
"""Async HTTP client where each route handler gets its own DB session.
|
|
|
|
Each request gets an independent session/connection so there are no
|
|
'another operation is in progress' errors from asyncpg.
|
|
"""
|
|
from src.db import get_db
|
|
|
|
app = create_app()
|
|
|
|
async def _override_get_db():
|
|
async with AsyncSession(_test_engine, expire_on_commit=False) as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
app.dependency_overrides[get_db] = _override_get_db
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
|
|
# ── Auth helper fixtures ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="session")
|
|
async def test_org(_test_engine, _setup_db):
|
|
"""Create a test organisation in the database."""
|
|
from src.models.organisation import Organisation
|
|
|
|
async with AsyncSession(_test_engine, expire_on_commit=False) as session:
|
|
org = Organisation(
|
|
id=uuid.uuid4(),
|
|
name="Test Organisation",
|
|
slug=f"test-org-{uuid.uuid4().hex[:8]}",
|
|
)
|
|
session.add(org)
|
|
await session.commit()
|
|
return org
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="session")
|
|
async def test_user(_test_engine, _setup_db, test_org):
|
|
"""Create a test user (owner role) with a known password."""
|
|
from src.models.user import User
|
|
from src.services.auth import hash_password
|
|
|
|
async with AsyncSession(_test_engine, expire_on_commit=False) as session:
|
|
user = User(
|
|
id=uuid.uuid4(),
|
|
email=f"admin-{uuid.uuid4().hex[:8]}@test.com",
|
|
password_hash=hash_password("TestPassword123"),
|
|
full_name="Test Admin",
|
|
role="owner",
|
|
organisation_id=test_org.id,
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
return user
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="session")
|
|
async def auth_token(test_user):
|
|
"""Generate a valid JWT token for the test user."""
|
|
from src.services.auth import create_access_token
|
|
|
|
return create_access_token(
|
|
user_id=str(test_user.id),
|
|
organisation_id=str(test_user.organisation_id),
|
|
role=test_user.role,
|
|
email=test_user.email,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="session")
|
|
async def auth_headers(auth_token):
|
|
"""HTTP headers with a valid Bearer token."""
|
|
return {"Authorization": f"Bearer {auth_token}"}
|
|
|
|
|
|
# ── Shared helper for creating sites in integration tests ────────────
|
|
|
|
|
|
async def create_test_site(
|
|
client: AsyncClient,
|
|
headers: dict,
|
|
*,
|
|
domain_prefix: str = "test",
|
|
display_name: str = "Test Site",
|
|
) -> str:
|
|
"""Create a site via the API and return its ID.
|
|
|
|
This is a helper function (not a fixture) so it can be called
|
|
inline within each test, avoiding async fixture event-loop issues.
|
|
"""
|
|
resp = await client.post(
|
|
"/api/v1/sites/",
|
|
json={
|
|
"domain": f"{domain_prefix}-{uuid.uuid4().hex[:8]}.com",
|
|
"display_name": display_name,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 201, f"Failed to create test site: {resp.text}"
|
|
return resp.json()["id"]
|