forked from retoor/devplacepy
Converge every account onto every policy agreement it has not declined
An instance kept production-identical for extended manual testing is otherwise taxed forever by its own safety controls: five consents, a versioned terms gate on every mutating request, and every account predating the trust and safety commit reading terms_version NULL because init_db deliberately never backfills it. AcceptanceService grants each agreement to each account that has not declined it, so the instance stays production byte for byte while nobody clicks the same dialog again. It is opt-in, dry run by default, and one switch per agreement type. The application is not allowed to know it exists. One registration line in main.py is the only import anywhere, there is no route, schema, template, Devii tool or environment flag, and a unit test greps the tree and fails the suite if a second importer appears. The decline register needs no storage: the ledger is append-only in effect, the service only ever grants, so any withdrawn row was written by a human and that pair is never touched again. No provenance column, nothing to observe. Satisfaction is the gate's own expression, never a proxy, which is why the ordering is created_at then id exactly as consent_state selects, and why the live-account clauses are built with has_column: init_db ensures terms_version and deletion_requested_at but not is_active, so a hardcoded reference raises no such column on an instance where nobody was ever suspended. Every write is one conditional statement decided on the real rowcount, proven with sixteen processes racing one account to exactly one ledger row and one audit row. The two existing audit keys carry it, with actor kind service, because a service that silently mutated consent state would be the worst possible exception to the append-only rule. lensfl.md is the source brief accept.md records the design against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import (
|
||||
clear_settings_cache,
|
||||
get_setting,
|
||||
get_table,
|
||||
refresh_snapshot,
|
||||
set_setting,
|
||||
)
|
||||
from devplacepy.services.acceptance.agreements import AGREEMENTS, setting_key
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
SERVICE_URL = f"{BASE_URL}/admin/services/acceptance"
|
||||
|
||||
|
||||
def _admin(seeded_db):
|
||||
refresh_snapshot()
|
||||
key = get_table("users").find_one(username="alice_test")["api_key"]
|
||||
session = requests.Session()
|
||||
session.headers.update({"X-API-KEY": key, **JSON})
|
||||
return session
|
||||
|
||||
|
||||
def test_the_service_is_listed_and_stopped_by_default(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
r = admin.get(f"{BASE_URL}/admin/services/data")
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
entry = next(
|
||||
item for item in r.json()["services"] if item["name"] == "acceptance"
|
||||
)
|
||||
assert entry["enabled"] is False
|
||||
assert entry["status"] == "stopped"
|
||||
assert entry["title"] == "Acceptance convergence"
|
||||
|
||||
|
||||
def test_the_detail_page_renders_one_field_per_agreement(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
r = admin.get(f"{SERVICE_URL}/data")
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
fields = {field["key"]: field for field in r.json()["service"]["fields"]}
|
||||
for agreement in AGREEMENTS:
|
||||
field = fields[setting_key(agreement.kind)]
|
||||
assert field["type"] == "bool"
|
||||
assert field["value"] == "0"
|
||||
assert field["group"] == "Agreements"
|
||||
assert fields["acceptance_dry_run"]["value"] == "1"
|
||||
assert fields["service_acceptance_enabled"]["value"] == "0"
|
||||
|
||||
|
||||
def test_the_detail_page_renders_html_for_an_admin(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
r = requests.get(SERVICE_URL, headers={"X-API-KEY": admin.headers["X-API-KEY"]})
|
||||
assert r.status_code == 200
|
||||
assert "Acceptance convergence" in r.text
|
||||
|
||||
|
||||
def test_an_admin_saves_an_agreement_toggle(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
key = setting_key("container_credentials")
|
||||
original = get_setting(key, "0")
|
||||
try:
|
||||
r = admin.post(f"{SERVICE_URL}/config", data={key: "1"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert r.json().get("ok") is True
|
||||
refresh_snapshot()
|
||||
clear_settings_cache()
|
||||
assert get_setting(key, "0") == "1"
|
||||
finally:
|
||||
set_setting(key, original)
|
||||
|
||||
|
||||
def test_an_invalid_batch_size_is_refused(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
r = admin.post(f"{SERVICE_URL}/config", data={"acceptance_batch_size": "banana"})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["errors"]["acceptance_batch_size"]
|
||||
|
||||
|
||||
def test_a_guest_cannot_read_the_service(app_server):
|
||||
r = requests.get(f"{SERVICE_URL}/data", headers=JSON, allow_redirects=False)
|
||||
assert r.status_code in (302, 303, 401, 403)
|
||||
|
||||
|
||||
def test_the_master_switch_stays_off_for_the_suite(seeded_db):
|
||||
refresh_snapshot()
|
||||
clear_settings_cache()
|
||||
assert get_setting("service_acceptance_enabled", "0") != "1"
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,65 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import CONSENT_KINDS
|
||||
from devplacepy.services.acceptance.agreements import (
|
||||
AGREEMENTS,
|
||||
agreement_for,
|
||||
label_for,
|
||||
setting_key,
|
||||
)
|
||||
from devplacepy.services.acceptance.service import AcceptanceService
|
||||
|
||||
|
||||
def test_every_consent_kind_is_classified_as_an_agreement():
|
||||
assert {agreement.kind for agreement in AGREEMENTS} == set(CONSENT_KINDS)
|
||||
|
||||
|
||||
def test_setting_keys_are_unique_and_namespaced():
|
||||
keys = [setting_key(agreement.kind) for agreement in AGREEMENTS]
|
||||
assert len(keys) == len(set(keys))
|
||||
for key in keys:
|
||||
assert key.startswith("acceptance_grant_")
|
||||
|
||||
|
||||
def test_every_agreement_has_a_config_field_in_the_agreements_group():
|
||||
service = AcceptanceService()
|
||||
fields = {field.key: field for field in service.config_fields}
|
||||
for agreement in AGREEMENTS:
|
||||
field = fields[setting_key(agreement.kind)]
|
||||
assert field.type == "bool"
|
||||
assert field.default is False
|
||||
assert field.group == "Agreements"
|
||||
|
||||
|
||||
def test_labels_come_from_the_consent_registry():
|
||||
for agreement in AGREEMENTS:
|
||||
assert label_for(agreement.kind) == CONSENT_KINDS[agreement.kind]
|
||||
|
||||
|
||||
def test_only_terms_declares_a_gate_column():
|
||||
gated = [agreement.kind for agreement in AGREEMENTS if agreement.gate_column]
|
||||
assert gated == ["terms"]
|
||||
|
||||
|
||||
def test_versioned_agreements_match_the_application_version_keys():
|
||||
from devplacepy.routers.profile.consent import VERSION_KEYS
|
||||
|
||||
versioned = {
|
||||
agreement.kind: agreement.version_setting
|
||||
for agreement in AGREEMENTS
|
||||
if agreement.version_setting
|
||||
}
|
||||
assert versioned == VERSION_KEYS
|
||||
|
||||
|
||||
def test_agreement_lookup_is_total_over_the_registry():
|
||||
for agreement in AGREEMENTS:
|
||||
assert agreement_for(agreement.kind) is agreement
|
||||
assert agreement_for("not_a_consent") is None
|
||||
|
||||
|
||||
def test_the_service_is_opt_in_and_runs_every_five_minutes():
|
||||
service = AcceptanceService()
|
||||
assert service.default_enabled is False
|
||||
assert service.interval_seconds == 300
|
||||
assert service.is_enabled() is False
|
||||
@@ -0,0 +1,147 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import (
|
||||
CONSENTS_TABLE,
|
||||
consent_granted,
|
||||
get_setting,
|
||||
get_table,
|
||||
set_consent,
|
||||
set_setting,
|
||||
)
|
||||
from devplacepy.services.acceptance.agreements import agreement_for
|
||||
from devplacepy.services.acceptance.grant import converge_user
|
||||
from devplacepy.services.audit.store import AUDIT_TABLE
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
TERMS = agreement_for("terms")
|
||||
AI = agreement_for("ai_third_party")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(local_db):
|
||||
uid = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"grantprobe_{uid[:8]}",
|
||||
"email": f"grantprobe_{uid[:8]}@example.com",
|
||||
"password_hash": "x",
|
||||
"role": "Member",
|
||||
"terms_version": "",
|
||||
"terms_accepted_at": "",
|
||||
"deletion_requested_at": "",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
}
|
||||
)
|
||||
yield get_table("users").find_one(uid=uid)
|
||||
get_table("users").delete(uid=uid)
|
||||
get_table(CONSENTS_TABLE).delete(owner_id=uid)
|
||||
get_table(AUDIT_TABLE).delete(target_uid=uid)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def versions():
|
||||
before = get_setting("terms_version", "1")
|
||||
yield
|
||||
set_setting("terms_version", before)
|
||||
|
||||
|
||||
def consent_rows(uid, kind):
|
||||
return list(
|
||||
get_table(CONSENTS_TABLE).find(
|
||||
owner_kind="user", owner_id=uid, kind=kind, deleted_at=None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def audit_rows(uid, event_key):
|
||||
return list(
|
||||
get_table(AUDIT_TABLE).find(
|
||||
target_uid=uid, event_key=event_key, actor_kind="service"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_a_won_terms_claim_writes_the_gate_column_and_the_ledger(account, versions):
|
||||
set_setting("terms_version", "5")
|
||||
assert converge_user(TERMS, account, "5") is True
|
||||
row = get_table("users").find_one(uid=account["uid"])
|
||||
assert row["terms_version"] == "5"
|
||||
assert row["terms_accepted_at"]
|
||||
written = consent_rows(account["uid"], "terms")
|
||||
assert len(written) == 1
|
||||
assert written[0]["state"] == "granted"
|
||||
assert written[0]["version"] == "5"
|
||||
|
||||
|
||||
def test_a_second_claim_at_the_same_version_writes_nothing(account, versions):
|
||||
set_setting("terms_version", "5")
|
||||
assert converge_user(TERMS, account, "5") is True
|
||||
assert converge_user(TERMS, account, "5") is False
|
||||
assert len(consent_rows(account["uid"], "terms")) == 1
|
||||
assert len(audit_rows(account["uid"], "terms.accept")) == 1
|
||||
|
||||
|
||||
def test_a_withdrawn_terms_consent_is_never_converged(account, versions):
|
||||
set_setting("terms_version", "5")
|
||||
set_consent("user", account["uid"], "terms", False, version="5")
|
||||
assert converge_user(TERMS, account, "5") is False
|
||||
assert get_table("users").find_one(uid=account["uid"])["terms_version"] == ""
|
||||
|
||||
|
||||
def test_a_withdrawn_ledger_agreement_is_never_converged(account):
|
||||
set_consent("user", account["uid"], "ai_third_party", False)
|
||||
assert converge_user(AI, account, "1") is False
|
||||
assert not consent_granted("user", account["uid"], "ai_third_party")
|
||||
|
||||
|
||||
def test_a_ledger_agreement_converges_once(account):
|
||||
assert converge_user(AI, account, "1") is True
|
||||
assert consent_granted("user", account["uid"], "ai_third_party")
|
||||
assert converge_user(AI, account, "1") is False
|
||||
granted = [
|
||||
row for row in consent_rows(account["uid"], "ai_third_party")
|
||||
if row["state"] == "granted"
|
||||
]
|
||||
assert len(granted) == 1
|
||||
|
||||
|
||||
def test_the_written_row_matches_the_human_path_field_for_field(account):
|
||||
reference = set_consent("user", account["uid"], "container_credentials", True)
|
||||
get_table(CONSENTS_TABLE).delete(uid=reference["uid"])
|
||||
assert converge_user(AI, account, "1") is True
|
||||
written = consent_rows(account["uid"], "ai_third_party")[0]
|
||||
assert set(written.keys()) == set(reference.keys())
|
||||
assert written["owner_kind"] == reference["owner_kind"]
|
||||
assert written["state"] == reference["state"]
|
||||
assert written["withdrawn_at"] == reference["withdrawn_at"]
|
||||
assert written["deleted_at"] is None
|
||||
assert written["deleted_by"] is None
|
||||
assert written["granted_at"] == written["created_at"]
|
||||
|
||||
|
||||
def test_a_won_claim_records_an_existing_event_key_as_a_service_actor(account, versions):
|
||||
set_setting("terms_version", "5")
|
||||
converge_user(TERMS, account, "5")
|
||||
converge_user(AI, account, "1")
|
||||
accepted = audit_rows(account["uid"], "terms.accept")
|
||||
granted = audit_rows(account["uid"], "consent.grant")
|
||||
assert len(accepted) == 1
|
||||
assert len(granted) == 1
|
||||
assert accepted[0]["actor_username"] == "acceptance"
|
||||
assert accepted[0]["origin"] == "service"
|
||||
assert accepted[0]["new_value"] == "5"
|
||||
assert granted[0]["new_value"] == "granted"
|
||||
|
||||
|
||||
def test_a_lost_claim_records_nothing(account, versions):
|
||||
set_setting("terms_version", "5")
|
||||
converge_user(TERMS, account, "5")
|
||||
converge_user(TERMS, account, "5")
|
||||
assert len(audit_rows(account["uid"], "terms.accept")) == 1
|
||||
|
||||
|
||||
def test_an_account_without_a_uid_is_refused():
|
||||
assert converge_user(AI, {}, "1") is False
|
||||
@@ -0,0 +1,68 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[4] / "devplacepy"
|
||||
PACKAGE_DIR = PACKAGE_ROOT / "services" / "acceptance"
|
||||
NEEDLES = (
|
||||
"services.acceptance",
|
||||
"devplacepy/services/acceptance",
|
||||
"AcceptanceService",
|
||||
)
|
||||
|
||||
|
||||
def source_files(suffixes):
|
||||
return [
|
||||
path
|
||||
for path in PACKAGE_ROOT.rglob("*")
|
||||
if path.suffix in suffixes
|
||||
and path.is_file()
|
||||
and PACKAGE_DIR not in path.parents
|
||||
and "__pycache__" not in path.parts
|
||||
]
|
||||
|
||||
|
||||
def references(path):
|
||||
body = path.read_text()
|
||||
return any(needle in body for needle in NEEDLES)
|
||||
|
||||
|
||||
def test_only_main_imports_the_acceptance_package():
|
||||
importers = [
|
||||
path.relative_to(PACKAGE_ROOT).as_posix()
|
||||
for path in source_files({".py"})
|
||||
if references(path)
|
||||
]
|
||||
assert importers == ["main.py"]
|
||||
|
||||
|
||||
def test_no_template_or_static_asset_mentions_the_package():
|
||||
mentions = [
|
||||
path.relative_to(PACKAGE_ROOT).as_posix()
|
||||
for path in source_files({".html", ".js", ".css"})
|
||||
if references(path)
|
||||
]
|
||||
assert mentions == []
|
||||
|
||||
|
||||
def test_the_feature_has_no_route_schema_or_agent_tool():
|
||||
watched = ("routers", "schemas", "docs_api")
|
||||
surfaced = [
|
||||
path.relative_to(PACKAGE_ROOT).as_posix()
|
||||
for path in source_files({".py"})
|
||||
if path.parts[len(PACKAGE_ROOT.parts)] in watched and references(path)
|
||||
]
|
||||
assert surfaced == []
|
||||
|
||||
|
||||
def test_the_devii_catalog_does_not_expose_the_service():
|
||||
catalog = PACKAGE_ROOT / "services" / "devii" / "actions"
|
||||
mentions = [path.name for path in catalog.rglob("*.py") if references(path)]
|
||||
assert mentions == []
|
||||
|
||||
|
||||
def test_the_package_imports_no_router_and_no_template_global():
|
||||
for path in PACKAGE_DIR.glob("*.py"):
|
||||
body = path.read_text()
|
||||
assert "devplacepy.routers" not in body
|
||||
assert "devplacepy.templating" not in body
|
||||
@@ -0,0 +1,166 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import CONSENTS_TABLE, get_setting, get_table, set_setting
|
||||
from devplacepy.services.acceptance.agreements import agreement_for
|
||||
from devplacepy.services.acceptance.pending import (
|
||||
LIVE_ACCOUNT_CLAUSES,
|
||||
current_version,
|
||||
live_account_clauses,
|
||||
pending,
|
||||
)
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
TERMS = agreement_for("terms")
|
||||
PRIVACY = agreement_for("privacy")
|
||||
AI = agreement_for("ai_third_party")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(local_db):
|
||||
uid = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"pendingprobe_{uid[:8]}",
|
||||
"email": f"pendingprobe_{uid[:8]}@example.com",
|
||||
"password_hash": "x",
|
||||
"role": "Member",
|
||||
"terms_version": "",
|
||||
"terms_accepted_at": "",
|
||||
"deletion_requested_at": "",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
}
|
||||
)
|
||||
yield uid
|
||||
get_table("users").delete(uid=uid)
|
||||
get_table(CONSENTS_TABLE).delete(owner_id=uid)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def versions():
|
||||
before = {
|
||||
"terms_version": get_setting("terms_version", "1"),
|
||||
"privacy_version": get_setting("privacy_version", "1"),
|
||||
}
|
||||
yield
|
||||
for key, value in before.items():
|
||||
set_setting(key, value)
|
||||
|
||||
|
||||
def write_consent(uid, kind, state, version):
|
||||
get_table(CONSENTS_TABLE).insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": "user",
|
||||
"owner_id": uid,
|
||||
"kind": kind,
|
||||
"version": version,
|
||||
"state": state,
|
||||
"granted_at": "2021-01-01T00:00:00" if state == "granted" else "",
|
||||
"withdrawn_at": "" if state == "granted" else "2021-01-01T00:00:00",
|
||||
"created_at": "2021-01-01T00:00:00",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_pending(agreement, uid):
|
||||
return uid in {row["uid"] for row in pending(agreement, 5000)}
|
||||
|
||||
|
||||
def test_an_account_that_never_accepted_is_pending(account):
|
||||
assert is_pending(TERMS, account)
|
||||
assert is_pending(AI, account)
|
||||
|
||||
|
||||
def test_a_null_gate_column_is_pending_not_invisible(account, versions):
|
||||
set_setting("terms_version", "1")
|
||||
get_table("users").update({"uid": account, "terms_version": None}, ["uid"])
|
||||
assert is_pending(TERMS, account)
|
||||
|
||||
|
||||
def test_a_satisfied_gate_column_is_not_pending(account, versions):
|
||||
set_setting("terms_version", "7")
|
||||
get_table("users").update({"uid": account, "terms_version": "7"}, ["uid"])
|
||||
assert not is_pending(TERMS, account)
|
||||
|
||||
|
||||
def test_a_stale_gate_column_is_pending_again(account, versions):
|
||||
set_setting("terms_version", "7")
|
||||
get_table("users").update({"uid": account, "terms_version": "7"}, ["uid"])
|
||||
set_setting("terms_version", "8")
|
||||
assert is_pending(TERMS, account)
|
||||
|
||||
|
||||
def test_a_withdrawal_removes_the_account_permanently(account, versions):
|
||||
set_setting("terms_version", "1")
|
||||
write_consent(account, "terms", "withdrawn", "1")
|
||||
assert not is_pending(TERMS, account)
|
||||
set_setting("terms_version", "2")
|
||||
assert not is_pending(TERMS, account)
|
||||
get_table("users").update({"uid": account, "terms_version": None}, ["uid"])
|
||||
assert not is_pending(TERMS, account)
|
||||
|
||||
|
||||
def test_an_unversioned_agreement_is_satisfied_by_a_granted_row(account):
|
||||
write_consent(account, "ai_third_party", "granted", "1")
|
||||
assert not is_pending(AI, account)
|
||||
|
||||
|
||||
def test_a_versioned_ledger_agreement_tracks_the_policy_version(account, versions):
|
||||
set_setting("privacy_version", "3")
|
||||
write_consent(account, "privacy", "granted", "3")
|
||||
assert not is_pending(PRIVACY, account)
|
||||
set_setting("privacy_version", "4")
|
||||
assert is_pending(PRIVACY, account)
|
||||
|
||||
|
||||
def test_a_legacy_consent_row_without_a_version_is_stale(account, versions):
|
||||
set_setting("privacy_version", "1")
|
||||
write_consent(account, "privacy", "granted", None)
|
||||
assert is_pending(PRIVACY, account)
|
||||
|
||||
|
||||
def test_a_deleted_account_is_never_pending(account):
|
||||
get_table("users").update(
|
||||
{"uid": account, "deletion_requested_at": "2024-01-01T00:00:00"}, ["uid"]
|
||||
)
|
||||
assert not is_pending(TERMS, account)
|
||||
|
||||
|
||||
def test_a_deactivated_account_is_never_pending(account):
|
||||
get_table("users").update({"uid": account, "is_active": False}, ["uid"])
|
||||
assert not is_pending(TERMS, account)
|
||||
|
||||
|
||||
def test_the_batch_size_bounds_the_sweep_and_the_order_is_stable(account):
|
||||
first = [row["uid"] for row in pending(TERMS, 1)]
|
||||
second = [row["uid"] for row in pending(TERMS, 1)]
|
||||
assert len(first) <= 1
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_a_zero_batch_size_still_returns_at_most_one_row(account):
|
||||
assert len(pending(TERMS, 0)) <= 1
|
||||
|
||||
|
||||
def test_the_live_account_clauses_only_name_existing_columns(local_db):
|
||||
users = get_table("users")
|
||||
expected = [
|
||||
clause
|
||||
for column, clause in LIVE_ACCOUNT_CLAUSES
|
||||
if users.has_column(column)
|
||||
]
|
||||
assert live_account_clauses() == expected
|
||||
|
||||
|
||||
def test_an_empty_version_setting_falls_back_to_one(versions):
|
||||
set_setting("terms_version", "")
|
||||
assert current_version(TERMS) == "1"
|
||||
|
||||
|
||||
def test_an_unversioned_agreement_reports_version_one():
|
||||
assert current_version(AI) == "1"
|
||||
Reference in New Issue
Block a user