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:
184
apps/api/tests/test_routers_org_config.py
Normal file
184
apps/api/tests/test_routers_org_config.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Unit tests for org-config 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
|
||||
from src.services.auth import create_access_token
|
||||
|
||||
ORG_ID = uuid.uuid4()
|
||||
USER_ID = uuid.uuid4()
|
||||
|
||||
|
||||
def _auth_headers(role="owner"):
|
||||
token = create_access_token(
|
||||
user_id=USER_ID, organisation_id=ORG_ID, role=role, email="admin@test.com"
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _mock_org_config(**overrides):
|
||||
config = MagicMock()
|
||||
config.id = overrides.get("id", uuid.uuid4())
|
||||
config.organisation_id = overrides.get("organisation_id", ORG_ID)
|
||||
config.blocking_mode = overrides.get("blocking_mode")
|
||||
config.regional_modes = overrides.get("regional_modes")
|
||||
config.tcf_enabled = overrides.get("tcf_enabled")
|
||||
config.tcf_publisher_cc = overrides.get("tcf_publisher_cc")
|
||||
config.gcm_enabled = overrides.get("gcm_enabled")
|
||||
config.gcm_default = overrides.get("gcm_default")
|
||||
config.banner_config = overrides.get("banner_config")
|
||||
config.gpp_enabled = overrides.get("gpp_enabled")
|
||||
config.gpp_supported_apis = overrides.get("gpp_supported_apis")
|
||||
config.gpc_enabled = overrides.get("gpc_enabled")
|
||||
config.gpc_jurisdictions = overrides.get("gpc_jurisdictions")
|
||||
config.gpc_global_honour = overrides.get("gpc_global_honour")
|
||||
config.shopify_privacy_enabled = overrides.get("shopify_privacy_enabled")
|
||||
config.privacy_policy_url = overrides.get("privacy_policy_url")
|
||||
config.terms_url = overrides.get("terms_url")
|
||||
config.scan_schedule_cron = overrides.get("scan_schedule_cron")
|
||||
config.scan_max_pages = overrides.get("scan_max_pages")
|
||||
config.consent_expiry_days = overrides.get("consent_expiry_days")
|
||||
config.consent_retention_days = overrides.get("consent_retention_days")
|
||||
config.created_at = datetime.now(UTC)
|
||||
config.updated_at = datetime.now(UTC)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
return create_app()
|
||||
|
||||
|
||||
async def _client(app, mock_session):
|
||||
from src.db import get_db
|
||||
|
||||
async def _override():
|
||||
yield mock_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override
|
||||
transport = ASGITransport(app=app)
|
||||
return AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
def _mock_db_sequence(*results):
|
||||
"""Create a mock session returning different results on successive execute() calls."""
|
||||
session = AsyncMock()
|
||||
mock_results = []
|
||||
for r in results:
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = r
|
||||
mock_results.append(result)
|
||||
session.execute = AsyncMock(side_effect=mock_results)
|
||||
|
||||
_added = []
|
||||
|
||||
def _fake_add(obj):
|
||||
_added.append(obj)
|
||||
|
||||
session.add = MagicMock(side_effect=_fake_add)
|
||||
|
||||
async def _fake_flush():
|
||||
for obj in _added:
|
||||
if getattr(obj, "id", None) is None:
|
||||
obj.id = uuid.uuid4()
|
||||
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
|
||||
|
||||
|
||||
class TestGetOrgConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_existing_config(self, mock_app):
|
||||
"""GET /org-config/ returns existing config."""
|
||||
config = _mock_org_config(blocking_mode="opt_out", consent_expiry_days=180)
|
||||
db = _mock_db_sequence(config)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get("/api/v1/org-config/", headers=_auth_headers())
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["blocking_mode"] == "opt_out"
|
||||
assert data["consent_expiry_days"] == 180
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_auto_creates_when_missing(self, mock_app):
|
||||
"""GET /org-config/ auto-creates a blank config if none exists."""
|
||||
db = _mock_db_sequence(None) # no existing config
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get("/api/v1/org-config/", headers=_auth_headers())
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# All optional fields should be None
|
||||
assert data["blocking_mode"] is None
|
||||
assert data["tcf_enabled"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_requires_auth(self, mock_app):
|
||||
"""GET /org-config/ returns 401 without token."""
|
||||
db = _mock_db_sequence()
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get("/api/v1/org-config/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestUpdateOrgConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_config(self, mock_app):
|
||||
"""PUT /org-config/ updates existing config."""
|
||||
config = _mock_org_config()
|
||||
db = _mock_db_sequence(config)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.put(
|
||||
"/api/v1/org-config/",
|
||||
json={"blocking_mode": "opt_out", "consent_expiry_days": 90},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify setattr was called on the mock
|
||||
assert config.blocking_mode == "opt_out"
|
||||
assert config.consent_expiry_days == 90
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_creates_when_missing(self, mock_app):
|
||||
"""PUT /org-config/ creates config if none exists."""
|
||||
db = _mock_db_sequence(None) # no existing config
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.put(
|
||||
"/api/v1/org-config/",
|
||||
json={"tcf_enabled": True},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_requires_admin(self, mock_app):
|
||||
"""PUT /org-config/ returns 403 for viewers."""
|
||||
db = _mock_db_sequence()
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.put(
|
||||
"/api/v1/org-config/",
|
||||
json={"blocking_mode": "opt_in"},
|
||||
headers=_auth_headers(role="viewer"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_allows_editor_role_fails(self, mock_app):
|
||||
"""PUT /org-config/ returns 403 for editors (only owner/admin can update)."""
|
||||
db = _mock_db_sequence()
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.put(
|
||||
"/api/v1/org-config/",
|
||||
json={"blocking_mode": "opt_in"},
|
||||
headers=_auth_headers(role="editor"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user