diff --git a/.github/workflows/test-tos-gate.yml b/.github/workflows/test-tos-gate.yml
new file mode 100644
index 00000000..aba8584d
--- /dev/null
+++ b/.github/workflows/test-tos-gate.yml
@@ -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
diff --git a/main.py b/main.py
index bd1fb00d..2f947268 100644
--- a/main.py
+++ b/main.py
@@ -36,27 +36,6 @@ def define_env(env):
''
)
- @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 (
- '
'
- )
-
@env.macro
def subscribe_form(list_id, btn_text):
return (
diff --git a/requirements.txt b/requirements.txt
index e9a0f01f..6a0c4972 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/tests/test_tos_gate.py b/tests/test_tos_gate.py
new file mode 100644
index 00000000..c5be980b
--- /dev/null
+++ b/tests/test_tos_gate.py
@@ -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