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.
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
"""Unit tests for consent router — mocked database."""
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from src.main import create_app
|
|
|
|
|
|
def _mock_consent_record(**overrides):
|
|
"""Build a mock ConsentRecord ORM object."""
|
|
record = MagicMock()
|
|
record.id = overrides.get("id", uuid.uuid4())
|
|
record.site_id = overrides.get("site_id", uuid.uuid4())
|
|
record.visitor_id = overrides.get("visitor_id", "visitor-123")
|
|
record.ip_hash = "abc123"
|
|
record.user_agent_hash = "def456"
|
|
record.action = overrides.get("action", "accept_all")
|
|
record.categories_accepted = overrides.get("categories_accepted", ["necessary"])
|
|
record.categories_rejected = overrides.get("categories_rejected", [])
|
|
record.tc_string = overrides.get("tc_string")
|
|
record.gcm_state = overrides.get("gcm_state")
|
|
record.gpp_string = overrides.get("gpp_string")
|
|
record.gpc_detected = overrides.get("gpc_detected")
|
|
record.gpc_honoured = overrides.get("gpc_honoured")
|
|
record.page_url = overrides.get("page_url")
|
|
record.country_code = overrides.get("country_code")
|
|
record.region_code = overrides.get("region_code")
|
|
record.consented_at = overrides.get("consented_at", datetime.now(UTC))
|
|
return record
|
|
|
|
|
|
def _mock_db(scalar_one_or_none=None):
|
|
session = AsyncMock()
|
|
result = MagicMock()
|
|
result.scalar_one_or_none.return_value = scalar_one_or_none
|
|
session.execute.return_value = result
|
|
|
|
_added_objects = []
|
|
|
|
def _fake_add(obj):
|
|
_added_objects.append(obj)
|
|
|
|
session.add = MagicMock(side_effect=_fake_add)
|
|
|
|
async def _fake_flush():
|
|
"""Simulate DB flush — populate server-side defaults."""
|
|
for obj in _added_objects:
|
|
if getattr(obj, "id", None) is None:
|
|
obj.id = uuid.uuid4()
|
|
if hasattr(obj, "consented_at") and getattr(obj, "consented_at", None) is None:
|
|
obj.consented_at = datetime.now(UTC)
|
|
if hasattr(obj, "created_at") and getattr(obj, "created_at", None) is None:
|
|
obj.created_at = datetime.now(UTC)
|
|
if hasattr(obj, "updated_at") and getattr(obj, "updated_at", None) is None:
|
|
obj.updated_at = datetime.now(UTC)
|
|
|
|
session.flush = AsyncMock(side_effect=_fake_flush)
|
|
session.refresh = AsyncMock()
|
|
return session
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_app():
|
|
return create_app()
|
|
|
|
|
|
async def _client(app, mock_session):
|
|
from src.db import get_db
|
|
from src.services.dependencies import get_current_user, require_role
|
|
|
|
user = MagicMock()
|
|
user.organisation_id = uuid.uuid4()
|
|
user.role = "owner"
|
|
|
|
async def _override():
|
|
yield mock_session
|
|
|
|
app.dependency_overrides[get_db] = _override
|
|
app.dependency_overrides[get_current_user] = lambda: user
|
|
|
|
def _override_require_role(*_roles):
|
|
return lambda: user
|
|
|
|
app.dependency_overrides[require_role] = _override_require_role
|
|
|
|
transport = ASGITransport(app=app)
|
|
return AsyncClient(transport=transport, base_url="http://test")
|
|
|
|
|
|
class TestRecordConsent:
|
|
@pytest.mark.asyncio
|
|
async def test_record_consent_success(self, mock_app):
|
|
db = _mock_db()
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.post(
|
|
"/api/v1/consent/",
|
|
json={
|
|
"site_id": str(uuid.uuid4()),
|
|
"visitor_id": "visitor-123",
|
|
"action": "accept_all",
|
|
"categories_accepted": ["necessary", "analytics"],
|
|
"categories_rejected": [],
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_consent_reject_all(self, mock_app):
|
|
db = _mock_db()
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.post(
|
|
"/api/v1/consent/",
|
|
json={
|
|
"site_id": str(uuid.uuid4()),
|
|
"visitor_id": "visitor-456",
|
|
"action": "reject_all",
|
|
"categories_accepted": ["necessary"],
|
|
"categories_rejected": ["analytics", "marketing"],
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_consent_custom(self, mock_app):
|
|
db = _mock_db()
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.post(
|
|
"/api/v1/consent/",
|
|
json={
|
|
"site_id": str(uuid.uuid4()),
|
|
"visitor_id": "visitor-789",
|
|
"action": "custom",
|
|
"categories_accepted": ["necessary", "analytics"],
|
|
"categories_rejected": ["marketing"],
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_consent_invalid_action(self, mock_app):
|
|
db = _mock_db()
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.post(
|
|
"/api/v1/consent/",
|
|
json={
|
|
"site_id": str(uuid.uuid4()),
|
|
"visitor_id": "visitor-000",
|
|
"action": "invalid_action",
|
|
"categories_accepted": ["necessary"],
|
|
"categories_rejected": [],
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_consent_empty_visitor_id(self, mock_app):
|
|
db = _mock_db()
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.post(
|
|
"/api/v1/consent/",
|
|
json={
|
|
"site_id": str(uuid.uuid4()),
|
|
"visitor_id": "",
|
|
"action": "accept_all",
|
|
"categories_accepted": ["necessary"],
|
|
"categories_rejected": [],
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_record_consent_with_optional_fields(self, mock_app):
|
|
db = _mock_db()
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.post(
|
|
"/api/v1/consent/",
|
|
json={
|
|
"site_id": str(uuid.uuid4()),
|
|
"visitor_id": "visitor-opt",
|
|
"action": "accept_all",
|
|
"categories_accepted": ["necessary"],
|
|
"categories_rejected": [],
|
|
"tc_string": "CPXxRAAAA",
|
|
"gcm_state": {"analytics_storage": "granted"},
|
|
"page_url": "https://example.com",
|
|
"country_code": "GB",
|
|
"region_code": "ENG",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
|
|
class TestGetConsent:
|
|
@pytest.mark.asyncio
|
|
async def test_get_consent_found(self, mock_app):
|
|
record = _mock_consent_record()
|
|
db = _mock_db(scalar_one_or_none=record)
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.get(f"/api/v1/consent/{record.id}")
|
|
assert resp.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_consent_not_found(self, mock_app):
|
|
db = _mock_db(scalar_one_or_none=None)
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.get(f"/api/v1/consent/{uuid.uuid4()}")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestVerifyConsent:
|
|
@pytest.mark.asyncio
|
|
async def test_verify_consent_valid(self, mock_app):
|
|
record = _mock_consent_record()
|
|
db = _mock_db(scalar_one_or_none=record)
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.get(f"/api/v1/consent/verify/{record.id}")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["valid"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_consent_not_found(self, mock_app):
|
|
db = _mock_db(scalar_one_or_none=None)
|
|
async with await _client(mock_app, db) as client:
|
|
resp = await client.get(f"/api/v1/consent/verify/{uuid.uuid4()}")
|
|
assert resp.status_code == 404
|