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:
153
apps/scanner/tests/test_dark_pattern_detector.py
Normal file
153
apps/scanner/tests/test_dark_pattern_detector.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for dark pattern detection — mocks Playwright."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.dark_pattern_detector import (
|
||||
check_button_prominence,
|
||||
check_cookie_wall,
|
||||
check_pre_ticked_boxes,
|
||||
detect_dark_patterns,
|
||||
)
|
||||
|
||||
|
||||
class TestCheckButtonProminence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_accept_button_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.query_selector_all = AsyncMock(return_value=[])
|
||||
issues = await check_button_prominence(page)
|
||||
assert issues == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_reject_button_flagged(self) -> None:
|
||||
# Accept button visible, reject not found
|
||||
accept_el = AsyncMock()
|
||||
accept_el.is_visible = AsyncMock(return_value=True)
|
||||
accept_el.evaluate = AsyncMock(
|
||||
return_value={
|
||||
"width": 200,
|
||||
"height": 40,
|
||||
"area": 8000,
|
||||
"backgroundColor": "rgb(37, 99, 235)",
|
||||
"color": "rgb(255, 255, 255)",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600",
|
||||
"padding": "8px 16px",
|
||||
"text": "Accept All",
|
||||
"visible": True,
|
||||
}
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _mock_query(selector):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# First batch of calls = accept selectors, return button
|
||||
# Remaining calls = reject selectors, return empty
|
||||
if "Accept" in selector or "Allow" in selector or "accept" in selector:
|
||||
return [accept_el]
|
||||
return []
|
||||
|
||||
page = AsyncMock()
|
||||
page.query_selector_all = _mock_query
|
||||
|
||||
issues = await check_button_prominence(page)
|
||||
assert any(i.pattern == "missing_reject_button" for i in issues)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unequal_button_size_flagged(self) -> None:
|
||||
accept_el = AsyncMock()
|
||||
accept_el.is_visible = AsyncMock(return_value=True)
|
||||
accept_el.evaluate = AsyncMock(
|
||||
return_value={
|
||||
"width": 300,
|
||||
"height": 50,
|
||||
"area": 15000,
|
||||
"fontSize": 18,
|
||||
"fontWeight": "700",
|
||||
"text": "Accept All",
|
||||
"visible": True,
|
||||
}
|
||||
)
|
||||
|
||||
reject_el = AsyncMock()
|
||||
reject_el.is_visible = AsyncMock(return_value=True)
|
||||
reject_el.evaluate = AsyncMock(
|
||||
return_value={
|
||||
"width": 100,
|
||||
"height": 30,
|
||||
"area": 3000,
|
||||
"fontSize": 12,
|
||||
"fontWeight": "400",
|
||||
"text": "Reject",
|
||||
"visible": True,
|
||||
}
|
||||
)
|
||||
|
||||
async def _mock_query(selector):
|
||||
if "Accept" in selector or "Allow" in selector or "accept" in selector:
|
||||
return [accept_el]
|
||||
if "Reject" in selector or "Decline" in selector or "reject" in selector:
|
||||
return [reject_el]
|
||||
return []
|
||||
|
||||
page = AsyncMock()
|
||||
page.query_selector_all = _mock_query
|
||||
|
||||
issues = await check_button_prominence(page)
|
||||
assert any(i.pattern == "unequal_button_size" for i in issues)
|
||||
|
||||
|
||||
class TestCheckPreTickedBoxes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pre_ticked_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=[])
|
||||
issues = await check_pre_ticked_boxes(page)
|
||||
assert issues == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_ticked_non_essential_flagged(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(
|
||||
return_value=[
|
||||
{"name": "analytics", "label": "Analytics Cookies"},
|
||||
{"name": "marketing", "label": "Marketing Cookies"},
|
||||
]
|
||||
)
|
||||
issues = await check_pre_ticked_boxes(page)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].pattern == "pre_ticked_checkboxes"
|
||||
assert issues[0].severity == "critical"
|
||||
|
||||
|
||||
class TestCheckCookieWall:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_wall_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=False)
|
||||
issues = await check_cookie_wall(page)
|
||||
assert issues == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wall_detected(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=True)
|
||||
issues = await check_cookie_wall(page)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].pattern == "cookie_wall"
|
||||
assert issues[0].severity == "critical"
|
||||
|
||||
|
||||
class TestDetectDarkPatterns:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_banner_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.url = "https://example.com/"
|
||||
page.query_selector_all = AsyncMock(return_value=[])
|
||||
result = await detect_dark_patterns(page)
|
||||
assert result.banner_found is False
|
||||
assert result.issues == []
|
||||
Reference in New Issue
Block a user