forked from retoor/devplacepy
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries, so the web version and any client that speaks to it enforce the same rules. Moderation core (services/moderation/, database/moderation.py): a reportable target registry, the content filter and its choke points, the report queue with atomic resolution, enforcement actions, consent tracking, maturity gating, and account deletion with a grace window. Surfaces: POST /reports plus the member report list, /admin/moderation and the per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and account deletion under /profile, the report button and dialog partials, the maturity gate, and the moderation stylesheet and ReportDialog client. Every user-generated surface stays reportable by construction: new content tables are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a reason, and the registry test fails the suite on anything left unclassified. Docs: community guidelines, content moderation, intellectual property, privacy, terms, contact, and the admin-only moderation operations page, plus the moderation API group and the Devii moderation actions. Compliance record: applecomp.md is the requirement register, applechanges.md the gap analysis against this codebase, and appleimpl.md the implementation design they resolve to. Tests cover the report flow, admin moderation, consent, account deletion, terms acceptance, workspaces, and the registry invariant across the unit, api, and e2e tiers.
This commit is contained in:
@@ -21,6 +21,7 @@ def _cli_user(role="Member"):
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"role": role,
|
||||
|
||||
@@ -36,6 +36,7 @@ def env(tmp_path, monkeypatch):
|
||||
{
|
||||
"uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"terms_version": "1",
|
||||
"email": "ctestadmin@example.com",
|
||||
"api_key": generate_uid(),
|
||||
"password_hash": "",
|
||||
@@ -296,6 +297,74 @@ def test_run_as_uid_rejects_unknown_user(env):
|
||||
)
|
||||
|
||||
|
||||
def _other_member(uid="ctest-u2", username="ctestmember"):
|
||||
users = get_table("users")
|
||||
if not users.find_one(uid=uid):
|
||||
users.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@example.com",
|
||||
"api_key": generate_uid(),
|
||||
"password_hash": "",
|
||||
"role": "Member",
|
||||
"is_active": True,
|
||||
"created_at": "2020-01-01T00:00:00+00:00",
|
||||
}
|
||||
)
|
||||
refresh_snapshot()
|
||||
return uid
|
||||
|
||||
|
||||
def test_run_as_another_member_needs_their_credential_consent(env):
|
||||
from devplacepy.database import set_consent
|
||||
|
||||
other = _other_member()
|
||||
set_consent("user", other, "container_credentials", False)
|
||||
with pytest.raises(api.ContainerError) as refused:
|
||||
run_async(
|
||||
api.create_instance(
|
||||
env["project"],
|
||||
name="noconsent",
|
||||
run_as_uid=other,
|
||||
autostart=False,
|
||||
actor=("user", env["user"]["uid"]),
|
||||
)
|
||||
)
|
||||
assert "consent" in str(refused.value).lower()
|
||||
|
||||
|
||||
def test_run_as_another_member_is_allowed_once_they_consent(env):
|
||||
from devplacepy.database import set_consent
|
||||
|
||||
other = _other_member()
|
||||
set_consent("user", other, "container_credentials", True)
|
||||
instance = run_async(
|
||||
api.create_instance(
|
||||
env["project"],
|
||||
name="withconsent",
|
||||
run_as_uid=other,
|
||||
autostart=False,
|
||||
actor=("user", env["user"]["uid"]),
|
||||
)
|
||||
)
|
||||
assert instance["run_as_uid"] == other
|
||||
|
||||
|
||||
def test_run_as_yourself_never_needs_a_consent(env):
|
||||
instance = run_async(
|
||||
api.create_instance(
|
||||
env["project"],
|
||||
name="selfrunas",
|
||||
run_as_uid=env["user"]["uid"],
|
||||
autostart=False,
|
||||
actor=("user", env["user"]["uid"]),
|
||||
)
|
||||
)
|
||||
assert instance["run_as_uid"] == env["user"]["uid"]
|
||||
|
||||
|
||||
def test_start_on_boot_pass_forces_running(env):
|
||||
inst = _ready_instance(env, name="boot", start_on_boot=True, autostart=False)
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
|
||||
@@ -17,6 +17,7 @@ def _seed_dbapi_schema(local_db):
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"username": "dbapi_seed",
|
||||
"terms_version": "1",
|
||||
"email": "seed@dbapi.test",
|
||||
"role": "Member",
|
||||
"xp": 0,
|
||||
|
||||
@@ -52,6 +52,7 @@ def _make_user_devii_quota(role="Member"):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"role": role,
|
||||
|
||||
@@ -20,6 +20,7 @@ def _make_user_at(role, created_at):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"role": role,
|
||||
"created_at": created_at.isoformat(),
|
||||
|
||||
@@ -87,6 +87,7 @@ def test_scheduled_tasks_only_ever_resolve_a_main_channel_session(local_db):
|
||||
{
|
||||
"uid": "sess-docs-sched",
|
||||
"username": "sched-owner",
|
||||
"terms_version": "1",
|
||||
"role": "Admin",
|
||||
"api_key": "k",
|
||||
"deleted_at": None,
|
||||
@@ -144,6 +145,7 @@ def _task_session(local_db, uid, role):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": uid,
|
||||
"terms_version": "1",
|
||||
"role": role,
|
||||
"api_key": "k",
|
||||
"deleted_at": None,
|
||||
|
||||
@@ -17,6 +17,7 @@ def _account(local_db, role):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"ctx-{uid[-10:]}",
|
||||
"terms_version": "1",
|
||||
"role": role,
|
||||
"deleted_at": None,
|
||||
"created_at": SIGNUP_AFTER_PRIMARY_ADMIN,
|
||||
|
||||
@@ -33,6 +33,7 @@ def _account(local_db, role):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"guard-{uid[-10:]}",
|
||||
"terms_version": "1",
|
||||
"role": role,
|
||||
"deleted_at": None,
|
||||
"created_at": SIGNUP_AFTER_PRIMARY_ADMIN,
|
||||
|
||||
@@ -16,6 +16,7 @@ def _account(local_db, role):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"lim-{uid[-10:]}",
|
||||
"terms_version": "1",
|
||||
"role": role,
|
||||
"deleted_at": None,
|
||||
"created_at": SIGNUP_AFTER_PRIMARY_ADMIN,
|
||||
|
||||
@@ -28,6 +28,7 @@ def _account(local_db, role):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"sched-{uid[-10:]}",
|
||||
"terms_version": "1",
|
||||
"role": role,
|
||||
"api_key": "k",
|
||||
"deleted_at": None,
|
||||
|
||||
@@ -26,6 +26,7 @@ def _account(local_db, role):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"store-{uid[-10:]}",
|
||||
"terms_version": "1",
|
||||
"role": role,
|
||||
"deleted_at": None,
|
||||
"created_at": SIGNUP_AFTER_PRIMARY_ADMIN,
|
||||
|
||||
@@ -17,6 +17,7 @@ def _user(username):
|
||||
{
|
||||
"uid": f"uid-{username}",
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"role": "Member",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ def _make_user_issues_gitea():
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"role": "member",
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
|
||||
@@ -96,6 +96,7 @@ def _user(prefix):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -13,6 +13,7 @@ def _user_uid():
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"tkt_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@tkt.dev",
|
||||
"password_hash": "x",
|
||||
"role": "Member",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.moderation import filter as content_filter
|
||||
from devplacepy.services.moderation.rules import (
|
||||
ALWAYS_BLOCK_CATEGORIES,
|
||||
FILTER_MODES,
|
||||
RULES,
|
||||
matches,
|
||||
score_for,
|
||||
)
|
||||
|
||||
MODE_ORDER = content_filter.MODE_ORDER
|
||||
VERDICT_ORDER = content_filter.VERDICT_ORDER
|
||||
|
||||
DEVELOPER_PROSE = [
|
||||
"Here is a proof of concept for CVE-2024-1234, the exploit overwrites the stack.",
|
||||
"The kernel panic dump shows a segmentation fault in the parser.",
|
||||
"Reverse engineering this malware sample took two evenings of disassembly.",
|
||||
"I hate this framework and everyone who defends it should read the source.",
|
||||
"Traceback (most recent call last): RuntimeError: process was killed",
|
||||
"Our red team ran a penetration test against the staging cluster.",
|
||||
"This weapon of a regex kills my CPU: (a+)+$",
|
||||
"The build failed with 'fatal error: cannot execute'.",
|
||||
]
|
||||
|
||||
PROHIBITED_PROSE = [
|
||||
("i will kill you when i find you", "violence"),
|
||||
("go kill yourself, nobody wants you here", "harassment"),
|
||||
("selling stolen credit cards and bank logs here", "illegal"),
|
||||
("how to build a pipe bomb at home", "weapons"),
|
||||
]
|
||||
|
||||
|
||||
def test_every_rule_has_a_positive_weight_and_a_known_category():
|
||||
from devplacepy.database.moderation import REPORT_REASONS
|
||||
|
||||
for rule in RULES:
|
||||
assert rule.weight > 0
|
||||
assert rule.category in REPORT_REASONS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", DEVELOPER_PROSE)
|
||||
def test_developer_prose_is_never_flagged(text, local_db):
|
||||
result = content_filter.classify(text, "review")
|
||||
assert result.verdict == "allow", f"{text!r} -> {result}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,category", PROHIBITED_PROSE)
|
||||
def test_prohibited_prose_is_flagged_with_its_category(text, category, local_db):
|
||||
result = content_filter.classify(text, "review")
|
||||
assert result.flagged
|
||||
assert category in result.categories
|
||||
|
||||
|
||||
def test_the_always_block_categories_block_in_every_active_mode(local_db):
|
||||
text = "selling child porn here"
|
||||
for mode in ("review", "block"):
|
||||
assert content_filter.classify(text, mode).verdict == "block"
|
||||
assert content_filter.classify(text, "off").verdict == "allow"
|
||||
|
||||
|
||||
def test_score_is_monotonic_in_the_number_of_matched_rules():
|
||||
previous = 0
|
||||
accumulated = ""
|
||||
for text, _ in PROHIBITED_PROSE:
|
||||
accumulated = f"{accumulated}\n{text}"
|
||||
current = score_for(accumulated, matches(accumulated))
|
||||
assert current >= previous
|
||||
previous = current
|
||||
|
||||
|
||||
def test_technical_context_only_ever_lowers_the_score():
|
||||
for text, _ in PROHIBITED_PROSE:
|
||||
plain = score_for(text, matches(text))
|
||||
technical = f"{text}\nThis is a malware analysis write-up."
|
||||
discounted = score_for(technical, matches(technical))
|
||||
assert discounted <= plain
|
||||
|
||||
|
||||
def test_a_verdict_never_weakens_as_the_mode_strengthens():
|
||||
samples = DEVELOPER_PROSE + [text for text, _ in PROHIBITED_PROSE]
|
||||
for text in samples:
|
||||
previous = -1
|
||||
for mode in sorted(FILTER_MODES, key=lambda name: MODE_ORDER[name]):
|
||||
current = VERDICT_ORDER[content_filter.classify(text, mode).verdict]
|
||||
assert current >= previous, f"{text!r} weakened at mode {mode}"
|
||||
previous = current
|
||||
|
||||
|
||||
def test_resolve_verdict_is_total_over_every_mode_and_verdict():
|
||||
for mode in FILTER_MODES:
|
||||
for verdict in VERDICT_ORDER:
|
||||
resolved = content_filter.resolve_verdict(verdict, mode)
|
||||
assert resolved in VERDICT_ORDER
|
||||
|
||||
|
||||
def test_classification_failure_falls_back_to_review(monkeypatch, local_db):
|
||||
def explode(text):
|
||||
raise RuntimeError("rule engine unavailable")
|
||||
|
||||
monkeypatch.setattr(content_filter, "matches", explode)
|
||||
result = content_filter.classify("anything at all", "review")
|
||||
assert result.verdict == "review"
|
||||
assert result.failed is True
|
||||
assert result.detail
|
||||
|
||||
|
||||
def test_empty_and_blank_text_is_allowed(local_db):
|
||||
for text in ("", " ", "\n\t"):
|
||||
assert content_filter.classify(text, "block").verdict == "allow"
|
||||
|
||||
|
||||
def test_screen_reports_the_worst_field_and_names_every_flagged_one(local_db):
|
||||
screening = content_filter.screen(
|
||||
{
|
||||
"title": "a normal title",
|
||||
"content": "i will kill you when i find you",
|
||||
"extra": "buy now 100% free money",
|
||||
},
|
||||
"review",
|
||||
)
|
||||
assert screening.flagged
|
||||
assert "content" in screening.fields
|
||||
assert "extra" in screening.fields
|
||||
assert "title" not in screening.fields
|
||||
|
||||
|
||||
def test_always_block_categories_are_a_subset_of_the_rule_categories():
|
||||
assert ALWAYS_BLOCK_CATEGORIES <= {rule.category for rule in RULES}
|
||||
@@ -26,6 +26,7 @@ def _make_user_ai_usage_profile(role="Member"):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": api_key,
|
||||
"role": role,
|
||||
|
||||
@@ -83,6 +83,7 @@ def _make_admin_openai_gateway(role="Admin"):
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": api_key,
|
||||
"role": role,
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import uuid_utils
|
||||
|
||||
from devplacepy.config import PRESENCE_TIMEOUT_SECONDS, PRESENCE_WRITE_SECONDS
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import get_table, set_consent
|
||||
from devplacepy.services import presence
|
||||
|
||||
|
||||
@@ -57,10 +57,12 @@ def test_touch_writes_once_then_throttles(local_db):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"presence_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@presence.test",
|
||||
"last_seen": None,
|
||||
}
|
||||
)
|
||||
set_consent("user", uid, "activity_recording", True)
|
||||
presence._last_write.pop(uid, None)
|
||||
|
||||
presence.touch(uid)
|
||||
@@ -182,3 +184,32 @@ def test_online_candidates_tracks_beyond_the_display_limit(local_db):
|
||||
made.append(uid)
|
||||
tracked = {u["uid"] for u in presence.online_candidates()}
|
||||
assert set(made) <= tracked
|
||||
|
||||
|
||||
def test_touch_is_silent_without_activity_recording_consent(local_db):
|
||||
uid = str(uuid_utils.uuid7())
|
||||
users = get_table("users")
|
||||
users.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"presence_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@presence.test",
|
||||
"last_seen": None,
|
||||
}
|
||||
)
|
||||
presence._last_write.pop(uid, None)
|
||||
|
||||
presence.touch(uid)
|
||||
assert users.find_one(uid=uid)["last_seen"] is None
|
||||
|
||||
set_consent("user", uid, "activity_recording", True)
|
||||
presence._last_write.pop(uid, None)
|
||||
presence.touch(uid)
|
||||
assert users.find_one(uid=uid)["last_seen"]
|
||||
|
||||
set_consent("user", uid, "activity_recording", False)
|
||||
presence._last_write.pop(uid, None)
|
||||
written = users.find_one(uid=uid)["last_seen"]
|
||||
presence.touch(uid)
|
||||
assert users.find_one(uid=uid)["last_seen"] == written
|
||||
|
||||
@@ -44,6 +44,7 @@ def _user(prefix):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}{_counter[0]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{prefix}{_counter[0]}@t.dev",
|
||||
"role": "Member",
|
||||
"api_key": generate_uid(),
|
||||
|
||||
@@ -16,6 +16,7 @@ def _user(prefix):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}{_counter[0]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{prefix}{_counter[0]}@t.dev",
|
||||
"role": "Member",
|
||||
"api_key": generate_uid(),
|
||||
|
||||
@@ -21,6 +21,7 @@ def _user(prefix):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}{_counter[0]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{prefix}{_counter[0]}@t.dev",
|
||||
"role": "Member",
|
||||
"api_key": generate_uid(),
|
||||
|
||||
Reference in New Issue
Block a user