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:
@@ -12,6 +12,7 @@ def _make_user_cli(role="Member"):
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"role": role,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -138,6 +139,7 @@ def test_apikey_backfill_assigns_missing(local_db, capsys):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"cli_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@t.dev",
|
||||
"role": "Member",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -38,6 +38,8 @@ def _signup_project_visibility():
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
@@ -108,6 +110,7 @@ def _make_user(role="Member"):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"role": role,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -156,6 +159,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(),
|
||||
@@ -385,6 +389,7 @@ def test_enrich_items_attaches_author_and_extras(local_db):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ def _user_db_helpers():
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"dbh_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@t.dev",
|
||||
"created_at": _now_db_helpers(),
|
||||
}
|
||||
@@ -532,6 +533,7 @@ def test_get_uids_by_username_match_returns_matching_uids(local_db):
|
||||
{
|
||||
"uid": target,
|
||||
"username": "searchablehero",
|
||||
"terms_version": "1",
|
||||
"created_at": _now_db_helpers(),
|
||||
}
|
||||
)
|
||||
@@ -548,6 +550,7 @@ def test_text_search_clause_matches_author_username(local_db):
|
||||
{
|
||||
"uid": author,
|
||||
"username": "authorquery",
|
||||
"terms_version": "1",
|
||||
"created_at": _now_db_helpers(),
|
||||
}
|
||||
)
|
||||
@@ -570,6 +573,7 @@ def test_text_search_clause_without_author_field_ignores_username(local_db):
|
||||
{
|
||||
"uid": author,
|
||||
"username": "lonelyauthor",
|
||||
"terms_version": "1",
|
||||
"created_at": _now_db_helpers(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ def _user(prefix="award"):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"{uid[:8]}@t.dev",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"award_count": 0,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import (
|
||||
AGE_BANDS,
|
||||
MATURITY_TARGETS,
|
||||
REPORTABLE_TARGETS,
|
||||
REPORT_REASONS,
|
||||
SOFT_DELETE_TABLES,
|
||||
UNREPORTABLE_TABLES,
|
||||
age_band_for,
|
||||
years_between,
|
||||
)
|
||||
from devplacepy.database.moderation import MODERATION_TABLES
|
||||
|
||||
|
||||
GUIDELINE_REASONS = (
|
||||
"hate",
|
||||
"violence",
|
||||
"weapons",
|
||||
"sexual",
|
||||
"religious",
|
||||
"misinformation",
|
||||
"exploitative",
|
||||
)
|
||||
|
||||
|
||||
def test_every_visible_soft_delete_table_is_reportable():
|
||||
reportable_tables = set(REPORTABLE_TARGETS.values())
|
||||
excluded = set(UNREPORTABLE_TABLES) | set(MODERATION_TABLES)
|
||||
unclassified = [
|
||||
table
|
||||
for table in SOFT_DELETE_TABLES
|
||||
if table not in reportable_tables and table not in excluded
|
||||
]
|
||||
assert unclassified == []
|
||||
|
||||
|
||||
def test_exclusion_list_only_names_real_soft_delete_tables():
|
||||
unknown = [table for table in UNREPORTABLE_TABLES if table not in SOFT_DELETE_TABLES]
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_registry_tables_are_unique_and_maturity_is_a_subset():
|
||||
assert len(set(REPORTABLE_TARGETS.values())) == len(REPORTABLE_TARGETS)
|
||||
assert MATURITY_TARGETS <= set(REPORTABLE_TARGETS)
|
||||
|
||||
|
||||
def test_report_reasons_cover_every_guideline_category():
|
||||
for reason in GUIDELINE_REASONS:
|
||||
assert reason in REPORT_REASONS
|
||||
assert REPORT_REASONS["intellectual_property"]
|
||||
assert REPORT_REASONS["other"]
|
||||
|
||||
|
||||
def test_every_registry_target_resolves_to_a_url(local_db):
|
||||
from devplacepy.database import resolve_object_url
|
||||
|
||||
for target_type in REPORTABLE_TARGETS:
|
||||
url = resolve_object_url(target_type, "missing-uid")
|
||||
assert url.startswith("/")
|
||||
|
||||
|
||||
def test_resolve_object_url_points_at_the_owning_surface(local_db):
|
||||
from devplacepy.database import get_table, resolve_object_url
|
||||
|
||||
get_table("users").insert(
|
||||
{"uid": "mod-user-1", "username": "mod_user_1", "deleted_at": None}
|
||||
)
|
||||
get_table("messages").insert(
|
||||
{
|
||||
"uid": "mod-msg-1",
|
||||
"sender_uid": "mod-user-1",
|
||||
"receiver_uid": "mod-user-2",
|
||||
"content": "hello",
|
||||
}
|
||||
)
|
||||
try:
|
||||
assert resolve_object_url("user", "mod-user-1") == "/profile/mod_user_1"
|
||||
assert (
|
||||
resolve_object_url("message", "mod-msg-1")
|
||||
== "/messages?with_uid=mod-user-1"
|
||||
)
|
||||
assert resolve_object_url("devii_output", "anything") == "/devii"
|
||||
finally:
|
||||
get_table("users").delete(uid="mod-user-1")
|
||||
get_table("messages").delete(uid="mod-msg-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"age,expected",
|
||||
[
|
||||
(0, "under_min"),
|
||||
(12, "under_min"),
|
||||
(13, "13_15"),
|
||||
(15, "13_15"),
|
||||
(16, "16_17"),
|
||||
(17, "16_17"),
|
||||
(18, "adult"),
|
||||
(120, "adult"),
|
||||
],
|
||||
)
|
||||
def test_age_band_boundaries(age, expected):
|
||||
assert age_band_for(age) == expected
|
||||
|
||||
|
||||
def test_age_band_is_monotonic_across_the_whole_domain():
|
||||
order = {band: index for index, band in enumerate(AGE_BANDS)}
|
||||
previous = -1
|
||||
for age in range(0, 150):
|
||||
current = order[age_band_for(age)]
|
||||
assert current >= previous
|
||||
previous = current
|
||||
|
||||
|
||||
def test_years_between_handles_leap_days_and_exact_boundaries():
|
||||
assert years_between(date(2000, 2, 29), date(2018, 2, 28)) == 17
|
||||
assert years_between(date(2000, 2, 29), date(2018, 3, 1)) == 18
|
||||
assert years_between(date(2008, 1, 1), date(2026, 1, 1)) == 18
|
||||
assert years_between(date(2008, 1, 2), date(2026, 1, 1)) == 17
|
||||
|
||||
|
||||
def test_years_between_never_exceeds_the_calendar_difference():
|
||||
born = date(2005, 6, 15)
|
||||
for offset in range(0, 4000, 7):
|
||||
today = date.fromordinal(born.toordinal() + offset)
|
||||
years = years_between(born, today)
|
||||
assert 0 <= years <= today.year - born.year
|
||||
@@ -8,6 +8,7 @@ def test_primary_admin_ignores_accounts_without_a_signup_time(local_db):
|
||||
{
|
||||
"uid": "pa-real",
|
||||
"username": "pa-real",
|
||||
"terms_version": "1",
|
||||
"role": "Admin",
|
||||
"created_at": "1990-01-01T00:00:00",
|
||||
"deleted_at": None,
|
||||
@@ -17,6 +18,7 @@ def test_primary_admin_ignores_accounts_without_a_signup_time(local_db):
|
||||
{
|
||||
"uid": "pa-null",
|
||||
"username": "pa-null",
|
||||
"terms_version": "1",
|
||||
"role": "Admin",
|
||||
"created_at": None,
|
||||
"deleted_at": None,
|
||||
@@ -26,6 +28,7 @@ def test_primary_admin_ignores_accounts_without_a_signup_time(local_db):
|
||||
{
|
||||
"uid": "pa-blank",
|
||||
"username": "pa-blank",
|
||||
"terms_version": "1",
|
||||
"role": "Admin",
|
||||
"created_at": "",
|
||||
"deleted_at": None,
|
||||
@@ -54,6 +57,7 @@ def test_primary_admin_skips_deactivated_and_deleted_founders(local_db):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": uid,
|
||||
"terms_version": "1",
|
||||
"role": "Admin",
|
||||
"created_at": created_at,
|
||||
"is_active": active,
|
||||
|
||||
@@ -28,6 +28,8 @@ def _signup_project_file_lines():
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
@@ -97,6 +99,8 @@ def _signup_project_files():
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,8 @@ def _session_push():
|
||||
"email": f"{name}@test.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.routers.admin.users import _is_senior_admin, _seniority_key
|
||||
from devplacepy.routers.admin._shared import is_senior_admin, seniority_key
|
||||
|
||||
SENIOR_ADMIN = {
|
||||
"uid": "senior",
|
||||
@@ -23,39 +23,39 @@ OLD_MEMBER = {
|
||||
|
||||
|
||||
def test_junior_admin_cannot_manage_senior_admin():
|
||||
assert _is_senior_admin(JUNIOR_ADMIN, SENIOR_ADMIN) is True
|
||||
assert is_senior_admin(JUNIOR_ADMIN, SENIOR_ADMIN) is True
|
||||
|
||||
|
||||
def test_senior_admin_can_manage_junior_admin():
|
||||
assert _is_senior_admin(SENIOR_ADMIN, JUNIOR_ADMIN) is False
|
||||
assert is_senior_admin(SENIOR_ADMIN, JUNIOR_ADMIN) is False
|
||||
|
||||
|
||||
def test_members_are_never_protected():
|
||||
assert _is_senior_admin(JUNIOR_ADMIN, OLD_MEMBER) is False
|
||||
assert _is_senior_admin(SENIOR_ADMIN, OLD_MEMBER) is False
|
||||
assert is_senior_admin(JUNIOR_ADMIN, OLD_MEMBER) is False
|
||||
assert is_senior_admin(SENIOR_ADMIN, OLD_MEMBER) is False
|
||||
|
||||
|
||||
def test_acting_on_self_is_not_protected():
|
||||
assert _is_senior_admin(SENIOR_ADMIN, SENIOR_ADMIN) is False
|
||||
assert _is_senior_admin(JUNIOR_ADMIN, JUNIOR_ADMIN) is False
|
||||
assert is_senior_admin(SENIOR_ADMIN, SENIOR_ADMIN) is False
|
||||
assert is_senior_admin(JUNIOR_ADMIN, JUNIOR_ADMIN) is False
|
||||
|
||||
|
||||
def test_none_target_is_not_protected():
|
||||
assert _is_senior_admin(SENIOR_ADMIN, None) is False
|
||||
assert is_senior_admin(SENIOR_ADMIN, None) is False
|
||||
|
||||
|
||||
def test_equal_created_at_tiebreaks_on_id():
|
||||
earlier = {"uid": "e1", "role": "Admin", "created_at": "2024-01-01T00:00:00", "id": 3}
|
||||
later = {"uid": "e2", "role": "Admin", "created_at": "2024-01-01T00:00:00", "id": 5}
|
||||
assert _is_senior_admin(later, earlier) is True
|
||||
assert _is_senior_admin(earlier, later) is False
|
||||
assert is_senior_admin(later, earlier) is True
|
||||
assert is_senior_admin(earlier, later) is False
|
||||
|
||||
|
||||
def test_missing_created_at_sorts_oldest_failsafe():
|
||||
no_date = {"uid": "nc", "role": "Admin", "created_at": None, "id": 2}
|
||||
assert _is_senior_admin(SENIOR_ADMIN, no_date) is True
|
||||
assert is_senior_admin(SENIOR_ADMIN, no_date) is True
|
||||
|
||||
|
||||
def test_seniority_key_orders_by_created_at_then_id():
|
||||
assert _seniority_key(SENIOR_ADMIN) < _seniority_key(JUNIOR_ADMIN)
|
||||
assert _seniority_key({"created_at": None, "id": 0}) == ("", 0)
|
||||
assert seniority_key(SENIOR_ADMIN) < seniority_key(JUNIOR_ADMIN)
|
||||
assert seniority_key({"created_at": None, "id": 0}) == ("", 0)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.routers.workspaces import index_entries
|
||||
|
||||
OWNER = "wsidx-owner"
|
||||
STRANGER = "wsidx-stranger"
|
||||
PUBLIC_PROJECT = "wsidx-public"
|
||||
PRIVATE_PROJECT = "wsidx-private"
|
||||
|
||||
ROWS = [
|
||||
{
|
||||
"uid": "wsidx-instance-public",
|
||||
"project_uid": PUBLIC_PROJECT,
|
||||
"owner_uid": OWNER,
|
||||
"ingress_slug": "wsidx-pub",
|
||||
"name": "Public workspace",
|
||||
},
|
||||
{
|
||||
"uid": "wsidx-instance-private",
|
||||
"project_uid": PRIVATE_PROJECT,
|
||||
"owner_uid": OWNER,
|
||||
"ingress_slug": "wsidx-priv",
|
||||
"name": "Private workspace",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_workspaces(local_db):
|
||||
users = get_table("users")
|
||||
projects = get_table("projects")
|
||||
users.insert({"uid": OWNER, "username": "wsidx_owner", "role": "Member"})
|
||||
users.insert({"uid": STRANGER, "username": "wsidx_stranger", "role": "Member"})
|
||||
projects.insert(
|
||||
{
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
"uid": PUBLIC_PROJECT,
|
||||
"user_uid": OWNER,
|
||||
"slug": "wsidx-public-project",
|
||||
"title": "Public workspace project",
|
||||
"description": "The public project description.",
|
||||
"is_private": 0,
|
||||
}
|
||||
)
|
||||
projects.insert(
|
||||
{
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
"uid": PRIVATE_PROJECT,
|
||||
"user_uid": OWNER,
|
||||
"slug": "wsidx-private-project",
|
||||
"title": "Private workspace project",
|
||||
"description": "The private project description.",
|
||||
"is_private": 1,
|
||||
}
|
||||
)
|
||||
yield
|
||||
projects.delete(uid=PUBLIC_PROJECT)
|
||||
projects.delete(uid=PRIVATE_PROJECT)
|
||||
users.delete(uid=OWNER)
|
||||
users.delete(uid=STRANGER)
|
||||
|
||||
|
||||
def _by_slug(entries):
|
||||
return {entry["slug"]: entry for entry in entries}
|
||||
|
||||
|
||||
def test_every_row_carries_its_uid_and_owner_uid(seeded_workspaces):
|
||||
entries = _by_slug(index_entries(ROWS, None))
|
||||
assert entries["wsidx-pub"]["uid"] == "wsidx-instance-public"
|
||||
assert entries["wsidx-priv"]["uid"] == "wsidx-instance-private"
|
||||
for entry in entries.values():
|
||||
assert entry["owner_uid"] == OWNER
|
||||
|
||||
|
||||
def test_a_guest_never_sees_a_private_projects_description_or_url(seeded_workspaces):
|
||||
entries = _by_slug(index_entries(ROWS, None))
|
||||
assert entries["wsidx-pub"]["description"] == "The public project description."
|
||||
assert entries["wsidx-pub"]["project_url"] == "/projects/wsidx-public-project"
|
||||
assert entries["wsidx-priv"]["description"] == ""
|
||||
assert entries["wsidx-priv"]["project_url"] == ""
|
||||
|
||||
|
||||
def test_a_stranger_is_treated_exactly_like_a_guest(seeded_workspaces):
|
||||
viewer = {"uid": STRANGER, "username": "wsidx_stranger", "role": "Member"}
|
||||
entries = _by_slug(index_entries(ROWS, viewer))
|
||||
assert entries["wsidx-priv"]["description"] == ""
|
||||
assert entries["wsidx-priv"]["project_url"] == ""
|
||||
|
||||
|
||||
def test_the_project_owner_still_sees_their_private_project_fields(seeded_workspaces):
|
||||
viewer = {"uid": OWNER, "username": "wsidx_owner", "role": "Member"}
|
||||
entries = _by_slug(index_entries(ROWS, viewer))
|
||||
assert entries["wsidx-priv"]["description"] == "The private project description."
|
||||
assert entries["wsidx-priv"]["project_url"] == "/projects/wsidx-private-project"
|
||||
|
||||
|
||||
def test_a_published_workspace_is_always_listed_with_its_link(seeded_workspaces):
|
||||
entries = _by_slug(index_entries(ROWS, None))
|
||||
assert len(entries) == 2
|
||||
assert entries["wsidx-priv"]["name"] == "Private workspace"
|
||||
assert entries["wsidx-priv"]["url"].endswith("/p/wsidx-priv")
|
||||
assert entries["wsidx-priv"]["owner"] == "wsidx_owner"
|
||||
assert entries["wsidx-priv"]["maturity"] == "general"
|
||||
@@ -21,6 +21,8 @@ def _signup_media(prefix="media"):
|
||||
"email": f"{name}@test.devplace",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
@@ -135,6 +137,47 @@ def test_devii_delete_media_is_confirm_gated():
|
||||
assert "confirm" in blob
|
||||
|
||||
|
||||
def test_admin_report_subject_is_the_redacted_admin_user_model():
|
||||
from devplacepy.schemas import AdminReportOut, AdminUserOut
|
||||
|
||||
annotation = AdminReportOut.model_fields["subject"].annotation
|
||||
assert AdminUserOut in getattr(annotation, "__args__", (annotation,))
|
||||
assert "password_hash" not in AdminUserOut.model_fields
|
||||
assert "api_key" not in AdminUserOut.model_fields
|
||||
|
||||
|
||||
def test_accept_terms_page_reports_versions_not_consents():
|
||||
from devplacepy.schemas import AcceptTermsOut
|
||||
|
||||
assert set(AcceptTermsOut.model_fields) == {"terms_version", "accepted_version"}
|
||||
assert "consents" not in AcceptTermsOut.model_fields
|
||||
|
||||
|
||||
def test_the_workspace_index_row_carries_its_uid_and_owner():
|
||||
from devplacepy.schemas import WorkspaceIndexItemOut
|
||||
|
||||
for field in ("uid", "owner_uid", "description", "project_url"):
|
||||
assert field in WorkspaceIndexItemOut.model_fields
|
||||
|
||||
|
||||
def test_devii_reads_the_account_deletion_page_before_deleting():
|
||||
from devplacepy.services.devii.actions.catalog import ACTIONS
|
||||
from devplacepy.services.devii.actions.dispatcher import (
|
||||
CONFIRM_REQUIRED,
|
||||
confirmation_error,
|
||||
)
|
||||
|
||||
by_name = {action.name: action for action in ACTIONS}
|
||||
reader = by_name["view_account_deletion"]
|
||||
assert reader.method == "GET"
|
||||
assert reader.path == "/profile/{username}/delete"
|
||||
assert "view_account_deletion" not in CONFIRM_REQUIRED
|
||||
assert "delete_my_account" in CONFIRM_REQUIRED
|
||||
assert confirmation_error("delete_my_account", {}) is not None
|
||||
assert confirmation_error("delete_my_account", {"confirm": "true"}) is None
|
||||
assert any(param.name == "confirm" for param in by_name["delete_my_account"].params)
|
||||
|
||||
|
||||
def test_game_legacy_out_defaults():
|
||||
from devplacepy.schemas import GameLegacyOut
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -50,6 +50,7 @@ def _seed_user(username, email, is_active=True, **extra):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": email,
|
||||
"is_active": is_active,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -40,6 +40,7 @@ def _seed_user(username, email, is_active=True, **extra):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": email,
|
||||
"is_active": is_active,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -30,6 +30,7 @@ def _seed_user(role="Member", xp=0, level=1):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"terms_version": "1",
|
||||
"email": f"{username}@t.dev",
|
||||
"role": role,
|
||||
"xp": xp,
|
||||
@@ -167,6 +168,7 @@ def _seed_user_at(role, created_at):
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"pa_{uid[:8]}",
|
||||
"terms_version": "1",
|
||||
"email": f"pa_{uid[:8]}@t.dev",
|
||||
"role": role,
|
||||
"created_at": created_at.isoformat(),
|
||||
|
||||
Reference in New Issue
Block a user