feat: initial public release

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.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

241
apps/api/tests/conftest.py Normal file
View File

@@ -0,0 +1,241 @@
"""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"]