Gate blocked actions behind an in-place terms acceptance dialog

A member whose account has not accepted the terms in force now gets one
dialog on the action they attempted instead of a dead-end refusal. The
client handler is the single TermsGate, wired into every Http POST helper
so the four optimistic controllers cannot swallow the gate into an error
flash, and the original request is replayed once the acceptance is
recorded. Reading the site and deleting an account stay unblocked.

apple.md is the source brief the compliance research documents reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:25:57 +02:00
co-authored by Claude Opus 5
parent 8e9d3fad98
commit 91fac7fd67
38 changed files with 393 additions and 58 deletions
+51
View File
@@ -5,6 +5,7 @@ import time
import requests
from devplacepy.database import get_setting, get_table, refresh_snapshot, set_setting
from devplacepy.routers.auth.terms import TERMS_ACCEPTANCE_CODE
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
@@ -152,3 +153,53 @@ def test_bumping_the_version_gates_writes_but_never_reads(app_server):
assert allowed.status_code == 200
finally:
set_setting("terms_version", original)
def test_the_refusal_tells_a_fetch_client_how_to_resolve_it(app_server):
session, _, response = _signup()
assert response.status_code == 200
original = get_setting("terms_version", "1") or "1"
bumped = f"{original}-contract"
set_setting("terms_version", bumped)
try:
blocked = None
for _ in range(40):
blocked = session.post(
f"{BASE_URL}/posts/create",
data={"title": "gated", "content": "This write must be gated."},
headers=JSON,
)
if blocked.status_code == 403:
break
time.sleep(0.5)
assert blocked.status_code == 403
error = blocked.json()["error"]
assert error["code"] == TERMS_ACCEPTANCE_CODE
assert error["redirect"] == "/auth/accept-terms"
assert error["terms_version"] == bumped
assert error["message"]
finally:
set_setting("terms_version", original)
def test_a_browser_form_post_is_redirected_to_the_acceptance_page(app_server):
session, _, response = _signup()
assert response.status_code == 200
original = get_setting("terms_version", "1") or "1"
set_setting("terms_version", f"{original}-redirect")
try:
blocked = None
for _ in range(40):
blocked = session.post(
f"{BASE_URL}/posts/create",
data={"title": "gated", "content": "This write must be gated."},
headers={"Accept": "text/html"},
allow_redirects=False,
)
if blocked.status_code == 303:
break
time.sleep(0.5)
assert blocked.status_code == 303
assert blocked.headers["location"] == "/auth/accept-terms"
finally:
set_setting("terms_version", original)
+115
View File
@@ -0,0 +1,115 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from devplacepy.database import get_setting, get_table, refresh_snapshot, set_setting
from devplacepy.utils import clear_user_cache
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
def _seed_post(user):
session = requests.Session()
session.post(
f"{BASE_URL}/auth/login",
data={"email": user["email"], "password": user["password"]},
headers=JSON,
)
session.post(
f"{BASE_URL}/posts/create",
data={
"title": "Terms gate fixture post",
"content": "A post that exists so the vote control is present on the feed.",
},
headers=JSON,
)
def _accepted_version(username):
refresh_snapshot()
row = get_table("users").find_one(username=username)
return (row or {}).get("terms_version") or ""
def _restore_accepted_version(username, version):
refresh_snapshot()
row = get_table("users").find_one(username=username)
if not row:
return
get_table("users").update({"uid": row["uid"], "terms_version": version}, ["uid"])
clear_user_cache(row["uid"])
def _open_gate_dialog(page):
for _ in range(40):
page.locator(".post-card .vote-up").first.click()
dialog = page.locator(".dialog-overlay.visible")
try:
dialog.wait_for(state="visible", timeout=1500)
return dialog
except Exception:
time.sleep(0.5)
return None
def test_the_gate_dialog_accepts_the_terms_and_replays_the_action(alice, seeded_db):
page, _ = alice
username = seeded_db["alice"]["username"]
_seed_post(seeded_db["alice"])
original = get_setting("terms_version", "1") or "1"
accepted_before = _accepted_version(username)
bumped = f"{original}-e2eaccept"
set_setting("terms_version", bumped)
try:
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible", timeout=10000)
dialog = _open_gate_dialog(page)
assert dialog is not None, "the terms gate dialog never appeared"
expect(page.locator(".dialog-title")).to_have_text("Terms of Service")
expect(page.locator(".dialog-links a")).to_have_count(3)
expect(page.locator(".dialog-confirm")).to_have_text("Accept and continue")
assert _accepted_version(username) != bumped
page.locator(".dialog-confirm").click()
page.locator(".dialog-overlay.visible").wait_for(state="hidden", timeout=10000)
recorded = ""
for _ in range(40):
recorded = _accepted_version(username)
if recorded == bumped:
break
time.sleep(0.5)
assert recorded == bumped
finally:
set_setting("terms_version", original)
_restore_accepted_version(username, accepted_before)
def test_declining_the_gate_dialog_records_no_acceptance(alice, seeded_db):
page, _ = alice
username = seeded_db["alice"]["username"]
_seed_post(seeded_db["alice"])
original = get_setting("terms_version", "1") or "1"
accepted_before = _accepted_version(username)
bumped = f"{original}-e2edecline"
set_setting("terms_version", bumped)
try:
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible", timeout=10000)
dialog = _open_gate_dialog(page)
assert dialog is not None, "the terms gate dialog never appeared"
page.locator(".dialog-cancel").click()
page.locator(".dialog-overlay.visible").wait_for(state="hidden", timeout=10000)
assert _accepted_version(username) != bumped
finally:
set_setting("terms_version", original)
_restore_accepted_version(username, accepted_before)