tests/: add tos-gate test and CI workflow

29 tests across four classes:
- TestTosGatedDownloads (9) - macro unit tests: URL absent from HTML,
  base64/JSON roundtrip, group format encoding, checkbox attributes,
  prose_section_id, tos_url override
- TestTosCheckbox (4) - legacy macro: element presence, section ID, URL
  defaults/override
- TestTosGateJs (7) - JS file: function defined, noreferrer absent from link
  template but warning comment present, data-prose-group selector present, group
  branch present, prose hidden on uncheck
- TestStaticFiles (9) - no plain dl.3mdeb.com links, correct macro usage in
  variants/ files, tos-gate.js in mkdocs.yml, permalink: true, referrer meta in
  overrides/main.html

Signed-off-by: Artur Raglis <artur.raglis@3mdeb.com>
This commit is contained in:
Artur Raglis
2026-05-17 19:08:48 +02:00
parent 8d0d41b5ee
commit d61a48d134
4 changed files with 245 additions and 21 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: ToS gate tests
on:
push:
branches:
- master
- main
paths:
- main.py
- docs/javascripts/tos-gate.js
- overrides/main.html
- mkdocs.yml
- docs/**
- tests/test_tos_gate.py
- requirements.txt
pull_request:
paths:
- main.py
- docs/javascripts/tos-gate.js
- overrides/main.html
- mkdocs.yml
- docs/**
- tests/test_tos_gate.py
- requirements.txt
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run ToS gate tests
run: pytest tests/test_tos_gate.py -v
-21
View File
@@ -36,27 +36,6 @@ def define_env(env):
'</div>'
)
@env.macro
def tos_checkbox(section_id, tos_url="https://www.dasharo.com/pages/terms/"):
"""Render a ToS acceptance checkbox that reveals the section with the given id."""
checkbox_id = f"tos-cb-{section_id}"
onchange = (
f"document.getElementById('{section_id}').style.display="
f"this.checked?'':'none'"
)
return (
'<div class="tos-gate">'
f'<label class="tos-gate__label" for="{checkbox_id}">'
f'<input type="checkbox" id="{checkbox_id}" '
f'class="tos-gate__checkbox" onchange="{onchange}"> '
'I confirm that I have read and agree to the '
f'<a href="{tos_url}" target="_blank" rel="noopener noreferrer">'
'Dasharo Terms of Service</a> and all applicable open-source '
'licensing terms governing this firmware release.'
'</label>'
'</div>'
)
@env.macro
def subscribe_form(list_id, btn_text):
return (
+1
View File
@@ -46,3 +46,4 @@ watchdog==4.0.2
markdown-grid-tables==0.4.0
mkdocs-macros-plugin==1.3.7
mkdocs-include-markdown-plugin==7.1.5
pytest==8.3.2
+206
View File
@@ -0,0 +1,206 @@
"""
Red/green tests for the ToS gate feature (Tasks 0 & 1).
Covers:
- tos_gated_downloads macro unit tests
- tos-gate.js static assertions
- Markdown source files: no plain dl.3mdeb.com links in gated sections
- mkdocs.yml / overrides configuration
"""
import base64
import json
import re
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_macros():
"""Collect macro functions from main.py by simulating define_env."""
sys.path.insert(0, str(REPO_ROOT))
import main # noqa: PLC0415
macros: dict = {}
class _Env:
def macro(self, fn):
macros[fn.__name__] = fn
return fn
main.define_env(_Env())
return macros
@pytest.fixture(scope="session")
def macros():
return _load_macros()
@pytest.fixture(scope="session")
def tos_gate_js():
return (REPO_ROOT / "docs/javascripts/tos-gate.js").read_text()
# ---------------------------------------------------------------------------
# tos_gated_downloads unit tests
# ---------------------------------------------------------------------------
class TestTosGatedDownloads:
def test_url_not_in_html_source(self, macros):
"""URLs must be absent from rendered HTML — only encoded in data-payload."""
url = "https://dl.3mdeb.com/secret/firmware.zip"
html = macros["tos_gated_downloads"]("s", [{"label": "f", "url": url}])
assert url not in html
def test_payload_is_valid_base64_json(self, macros):
files = [{"label": "sha256", "url": "https://dl.3mdeb.com/file.sha256"}]
html = macros["tos_gated_downloads"]("s", files)
m = re.search(r'data-payload="([^"]+)"', html)
assert m, "data-payload attribute missing from output"
decoded = json.loads(base64.b64decode(m.group(1)))
assert decoded == files
def test_group_format_encodes_correctly(self, macros):
files = [{"group": "Raw image", "note": "(fw.rom)", "items": [
{"label": "sha256", "url": "https://dl.3mdeb.com/fw.sha256"},
]}]
html = macros["tos_gated_downloads"]("s", files)
assert "dl.3mdeb.com" not in html
m = re.search(r'data-payload="([^"]+)"', html)
decoded = json.loads(base64.b64decode(m.group(1)))
assert decoded[0]["group"] == "Raw image"
assert decoded[0]["note"] == "(fw.rom)"
assert decoded[0]["items"][0]["url"] == "https://dl.3mdeb.com/fw.sha256"
def test_checkbox_required_attributes(self, macros):
html = macros["tos_gated_downloads"]("my-section", [])
assert 'data-section="my-section"' in html
assert 'onchange="revealGated(this)"' in html
assert 'class="tos-gate__checkbox"' in html
def test_section_content_div_present(self, macros):
html = macros["tos_gated_downloads"]("my-section", [])
assert 'id="my-section"' in html
assert "tos-gate-content" in html
def test_prose_section_id_emits_data_attribute(self, macros):
html = macros["tos_gated_downloads"]("s", [], prose_section_id="my-prose")
assert 'data-prose-section="my-prose"' in html
def test_no_prose_section_id_omits_data_attribute(self, macros):
html = macros["tos_gated_downloads"]("s", [])
assert "data-prose-section" not in html
def test_default_tos_url(self, macros):
html = macros["tos_gated_downloads"]("s", [])
assert 'href="https://www.dasharo.com/pages/terms/"' in html
def test_custom_tos_url_override(self, macros):
html = macros["tos_gated_downloads"]("s", [], tos_url="https://example.com/tos")
assert 'href="https://example.com/tos"' in html
assert "dasharo.com/pages/terms" not in html
# ---------------------------------------------------------------------------
# tos-gate.js static assertions
# ---------------------------------------------------------------------------
class TestTosGateJs:
def test_file_exists(self):
assert (REPO_ROOT / "docs/javascripts/tos-gate.js").exists()
def test_reveal_gated_function_defined(self, tos_gate_js):
assert "function revealGated" in tos_gate_js
def test_noreferrer_absent_from_link_template(self, tos_gate_js):
"""noreferrer on injected download links would strip the Referer header,
breaking the MinIO bucket policy gate."""
for line in tos_gate_js.splitlines():
if "md-button" in line:
assert "noreferrer" not in line, (
f"noreferrer must not appear in the injected link template: {line!r}"
)
def test_noreferrer_warning_comment_present(self, tos_gate_js):
"""The intentional absence of noreferrer should be documented."""
assert "noreferrer" in tos_gate_js
def test_prose_group_queryselector_present(self, tos_gate_js):
"""Multiple prose divs must be toggled via data-prose-group selector."""
assert "data-prose-group" in tos_gate_js
def test_group_rendering_branch_present(self, tos_gate_js):
assert "f.group" in tos_gate_js
def test_prose_elements_toggled_on_uncheck(self, tos_gate_js):
"""Unchecking must hide prose sections, not just clear download buttons."""
assert 'display = "none"' in tos_gate_js or "display='none'" in tos_gate_js or \
'style.display = "none"' in tos_gate_js
# ---------------------------------------------------------------------------
# Static file tests variant markdown files (parameterized, opt-in)
#
# Tests run against every *.md under docs/variants/.
# Files that do not use tos_gated_downloads are skipped automatically,
# so adding the gate to a new variant is covered without touching this file.
# ---------------------------------------------------------------------------
_VARIANT_MD_FILES = sorted((REPO_ROOT / "docs/variants").rglob("*.md"))
_VARIANT_IDS = [str(f.relative_to(REPO_ROOT)) for f in _VARIANT_MD_FILES]
@pytest.mark.parametrize("md_file", _VARIANT_MD_FILES, ids=_VARIANT_IDS)
def test_no_plain_dl_links_in_gated_file(md_file):
"""Any file using tos_gated_downloads must not expose dl.3mdeb.com URLs
as plain markdown links (inline or reference-style)."""
content = md_file.read_text()
if "tos_gated_downloads" not in content:
pytest.skip("file does not use tos_gated_downloads")
assert not re.search(r"^\[.*\]:\s+https://dl\.3mdeb\.com/", content, re.MULTILINE), \
f"Reference-style link to dl.3mdeb.com found in {md_file.name}"
assert not re.search(r"\]\(https://dl\.3mdeb\.com/", content), \
f"Inline markdown link to dl.3mdeb.com found in {md_file.name}"
@pytest.mark.parametrize("md_file", _VARIANT_MD_FILES, ids=_VARIANT_IDS)
def test_prose_section_id_has_matching_div(md_file):
"""Any file using prose_section_id must have at least one data-prose-group div."""
content = md_file.read_text()
if "prose_section_id" not in content:
pytest.skip("file does not use prose_section_id")
assert "data-prose-group" in content, \
f"prose_section_id used but no data-prose-group div found in {md_file.name}"
def test_at_least_one_variant_uses_tos_gated_downloads():
"""Sanity check: ensure the feature is actually in use and hasn't been
accidentally removed from all variant files."""
gated = [f for f in _VARIANT_MD_FILES if "tos_gated_downloads" in f.read_text()]
assert gated, "No variant file uses tos_gated_downloads — feature may have been removed"
# ---------------------------------------------------------------------------
# Static file tests infrastructure configuration
# ---------------------------------------------------------------------------
class TestInfrastructure:
def test_mkdocs_yml_includes_tos_gate_js(self):
content = (REPO_ROOT / "mkdocs.yml").read_text()
assert "javascripts/tos-gate.js" in content
def test_mkdocs_yml_permalink_enabled(self):
content = (REPO_ROOT / "mkdocs.yml").read_text()
assert "permalink: true" in content
def test_overrides_main_html_referrer_meta(self):
content = (REPO_ROOT / "overrides/main.html").read_text()
assert 'name="referrer"' in content
assert "strict-origin-when-cross-origin" in content