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:
2026-08-09 00:18:20 +02:00
parent 68c2bbe387
commit 8e9d3fad98
348 changed files with 10633 additions and 238 deletions
+1
View File
@@ -40,6 +40,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,
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+4
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -111,6 +113,8 @@ def _member():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+2
View File
@@ -27,6 +27,8 @@ def _make_admin():
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+2
View File
@@ -25,6 +25,8 @@ def _make_admin():
"email": f"{name}@t.dev",
"password": password,
"confirm_password": password,
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
+1
View File
@@ -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,
+8
View File
@@ -42,6 +42,7 @@ def seed_extra_users(count=30):
{
"uid": uid,
"username": f"pagu_{i:04d}",
"terms_version": "1",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -55,6 +56,7 @@ def _seed_target_user():
{
"uid": uid,
"username": f"target_{uid[:8]}",
"terms_version": "1",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -111,6 +113,8 @@ def _member():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -194,6 +198,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,
)
@@ -296,6 +302,8 @@ def test_admin_users_pagination(alice):
"email": f"pagu{i:04d}@test.devplace",
"password": "testpass123",
"confirm_password": "testpass123",
"birth_date": "1990-01-01",
"accept_terms": "1",
}
).encode()
try:
+1
View File
@@ -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,
+1
View File
@@ -46,6 +46,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
+8
View File
@@ -15,6 +15,8 @@ def test_login_success(page, app_server):
page.fill("#email", "login@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@@ -34,6 +36,8 @@ def test_login_wrong_password(page, app_server):
page.fill("#email", "wrongpw@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@@ -52,6 +56,8 @@ def test_login_remember_me(page, app_server):
page.fill("#email", "remember@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@@ -72,6 +78,8 @@ def test_logout(page, app_server):
page.fill("#email", "logout@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
+1
View File
@@ -35,6 +35,7 @@ def test_reset_password_full_flow(page, app_server):
{
"uid": uid,
"username": uname,
"terms_version": "1",
"email": email,
"password_hash": hash_password("oldpass123"),
"bio": "",
+31
View File
@@ -69,6 +69,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
@@ -211,6 +212,8 @@ def test_signup_success(page, app_server):
page.fill("#email", "fresh@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
assert page.is_visible("text=fresh_user")
@@ -222,6 +225,8 @@ def test_signup_existing_username(page, app_server):
page.fill("#email", "dup1@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@@ -231,6 +236,8 @@ def test_signup_existing_username(page, app_server):
page.fill("#email", "dup2@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
expect(page.locator("text=Username already taken")).to_be_visible()
@@ -241,6 +248,8 @@ def test_signup_existing_email(page, app_server):
page.fill("#email", "sameemail@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@@ -250,6 +259,8 @@ def test_signup_existing_email(page, app_server):
page.fill("#email", "sameemail@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
expect(page.locator("text=Email already registered")).to_be_visible()
@@ -260,6 +271,8 @@ def test_signup_password_mismatch(page, app_server):
page.fill("#email", "mismatch@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "different456")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
expect(page.locator("text=Passwords do not match")).to_be_visible()
@@ -270,6 +283,8 @@ def test_signup_short_password(page, app_server):
page.fill("#email", "shortpw@test.devplace")
page.fill("#password", "ab")
page.fill("#confirm_password", "ab")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
expect(page.locator("text=Password must be at least 6 characters")).to_be_visible()
@@ -280,6 +295,8 @@ def test_signup_invalid_username(page, app_server):
page.fill("#email", "shortname@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
expect(
page.locator("text=Username must be between 3 and 32 characters")
@@ -316,6 +333,8 @@ def test_landing_authenticated_shows_dashboard(page, app_server):
page.fill("#email", "home@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
with page.expect_navigation(timeout=10000):
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@@ -346,6 +365,8 @@ def test_registration_closed_blocks_signup(alice):
"email": "closed_signup@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
)
assert "Registration is currently closed" in attempt.text
@@ -379,6 +400,8 @@ def test_session_length_setting_applies(alice, browser):
guest.fill("#email", f"{username}@test.devplace")
guest.fill("#password", "secret123")
guest.fill("#confirm_password", "secret123")
guest.fill("#birth_date", "1990-01-01")
guest.check("#accept_terms")
guest.click("button:has-text('Create account')")
guest.wait_for_url("**/feed", wait_until="domcontentloaded")
@@ -397,6 +420,8 @@ def test_feed_page_has_unique_title(page, app_server):
page.fill("#email", "seo_title@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
title = page.title()
@@ -416,6 +441,8 @@ def test_feed_page_has_canonical(page, app_server):
page.fill("#email", "seo_canon@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
link = page.locator('link[rel="canonical"]')
@@ -429,6 +456,8 @@ def test_feed_page_has_og_tags(page, app_server):
page.fill("#email", "seo_og@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
og = page.locator('meta[property="og:title"]')
@@ -442,6 +471,8 @@ def test_feed_page_has_twitter_card(page, app_server):
page.fill("#email", "seo_twit@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
card = page.locator('meta[name="twitter:card"]')
+2
View File
@@ -50,6 +50,8 @@ def _member():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+1
View File
@@ -40,6 +40,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,
+2
View File
@@ -30,6 +30,8 @@ def _author():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+6
View File
@@ -52,6 +52,7 @@ def _seed_posts(count):
{
"uid": owner,
"username": f"pag_{suite}_{i}",
"terms_version": "1",
"email": f"{suite}_{i}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -84,6 +85,7 @@ def _seed_post_with_comments(comment_texts, topic="devlog"):
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
@@ -148,6 +150,7 @@ def _seed_searchable_posts():
{
"uid": owner,
"username": f"srch_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -229,6 +232,8 @@ def _author():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -282,6 +287,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
+5
View File
@@ -24,6 +24,8 @@ def _register_session(prefix):
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -34,6 +36,7 @@ def _seed_gists(count):
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -99,6 +102,7 @@ def _seed_searchable_gists(language="python"):
{
"uid": owner,
"username": f"gsrch_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -408,6 +412,7 @@ def _seed_gist_with_comments():
{
"uid": owner,
"username": f"gcseed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@gc.seed",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -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,
)
+1
View File
@@ -42,6 +42,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -101,6 +101,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
@@ -322,6 +323,7 @@ def _seed_news_with_comments():
{
"uid": owner,
"username": f"ncseed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@nc.seed",
"password_hash": "x",
"role": "Member",
+3 -2
View File
@@ -41,6 +41,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
@@ -443,7 +444,7 @@ def test_message_notification(app_server, browser, seeded_db):
msg_input = pb.locator("textarea[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Hello from bob_test!")
pb.locator("button[type='submit']").last.click()
pb.locator(".messages-input-area button[type='submit']").last.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
@@ -756,7 +757,7 @@ def test_message_notification_click_opens_conversation(app_server, browser, seed
msg_input = pb.locator("textarea[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Click-through message from bob")
pb.locator("button[type='submit']").last.click()
pb.locator(".messages-input-area button[type='submit']").last.click()
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
+18
View File
@@ -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,
@@ -94,6 +95,13 @@ def _config(page, **fields):
page.request.post(f"{BASE_URL}/admin/services/openai/config", form=fields)
def _grant_ai_consent(page, username):
page.request.post(
f"{BASE_URL}/profile/{username}/consent",
form={"kind": "ai_third_party", "granted": "1"},
)
def test_gateway_disabled_returns_503(alice):
page, _ = alice
page.request.post(f"{BASE_URL}/admin/services/openai/stop")
@@ -130,6 +138,16 @@ def test_gateway_auth_and_routing(alice):
assert with_key.status_code == 502 # auth passed, upstream unreachable
admin_key = get_table("users").find_one(username=user["username"])["api_key"]
no_consent = requests.post(
f"{BASE_URL}/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {admin_key}"},
json={"messages": [{"role": "user", "content": "hi"}]},
)
assert no_consent.status_code == 403, (
"a user's own key must not reach the provider without ai_third_party consent"
)
_grant_ai_consent(page, user["username"])
with_admin = requests.post(
f"{BASE_URL}/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {admin_key}"},
+8
View File
@@ -9,6 +9,13 @@ def _config(page, **fields):
page.request.post(f"{BASE_URL}/admin/services/openai/config", form=fields)
def _grant_ai_consent(page, username):
page.request.post(
f"{BASE_URL}/profile/{username}/consent",
form={"kind": "ai_third_party", "granted": "1"},
)
def test_embeddings_disabled_returns_503(alice):
page, _ = alice
page.request.post(f"{BASE_URL}/admin/services/openai/stop")
@@ -53,6 +60,7 @@ def test_embeddings_auth_passes_upstream_unreachable(alice):
assert with_key.status_code == 502
admin_key = get_table("users").find_one(username=user["username"])["api_key"]
_grant_ai_consent(page, user["username"])
with_admin = requests.post(
f"{BASE_URL}/openai/v1/embeddings",
headers={"Authorization": f"Bearer {admin_key}"},
+3
View File
@@ -17,6 +17,8 @@ def _session_comments():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -69,6 +71,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
+6
View File
@@ -29,6 +29,7 @@ def _seed_posts(count):
{
"uid": owner,
"username": f"pag_{suite}_{i}",
"terms_version": "1",
"email": f"{suite}_{i}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -61,6 +62,7 @@ def _seed_post_with_comments(comment_texts, topic="devlog"):
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
@@ -125,6 +127,7 @@ def _seed_searchable_posts():
{
"uid": owner,
"username": f"srch_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -203,6 +206,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
@@ -518,6 +522,8 @@ def test_post_page_has_structured_data(page, app_server):
page.fill("#email", "seo_schema@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.locator(".feed-fab").click()
+1
View File
@@ -29,6 +29,7 @@ def _seed_user(prefix):
{
"uid": uid,
"username": f"{prefix}_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
+49 -102
View File
@@ -1,120 +1,67 @@
# retoor <retoor@molodetz.nl>
import re
from tests.conftest import BASE_URL
FOLLOW = "form[action='/follow/bob_test'] button"
UNFOLLOW = "form[action='/follow/unfollow/bob_test'] button"
from playwright.sync_api import expect
from tests.e2e.post import create_post
from tests.conftest import BASE_URL
CONSENT = "ai_third_party"
def test_profile_shows_online_presence(alice):
page, user = alice
def _privacy_tab(page, username):
page.goto(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
f"{BASE_URL}/profile/{username}?tab=privacy", wait_until="domcontentloaded"
)
presence = page.locator(".profile-presence")
expect(presence).to_be_visible()
expect(presence).to_have_class(re.compile(r"\bonline\b"))
expect(presence).to_have_text("online")
page.locator(".privacy-panel").wait_for(state="visible")
def test_nav_avatar_shows_online_presence_dot(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
nav_dot = page.locator(".topnav-user .presence-dot")
expect(nav_dot).to_be_visible()
expect(nav_dot).to_have_class(re.compile(r"\bonline\b"))
def test_the_owner_toggles_a_consent_from_the_privacy_tab(bob):
page, user = bob
_privacy_tab(page, user["username"])
row = page.locator(f".privacy-panel tr:has(input[value='{CONSENT}'])")
button = row.locator("button[type='submit']")
original = (button.inner_text() or "").strip()
flipped = "Withdraw" if original == "Grant" else "Grant"
try:
button.click()
expect(row.locator("button[type='submit']")).to_have_text(
flipped, timeout=10000
)
finally:
_privacy_tab(page, user["username"])
if (row.locator("button[type='submit']").inner_text() or "").strip() != original:
row.locator("button[type='submit']").click()
expect(row.locator("button[type='submit']")).to_have_text(
original, timeout=10000
)
def _post_comment(page, body):
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill(body)
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(f".comment-text:has-text('{body}')")).to_be_visible()
def test_activity_comment_card_links_to_parent_post(alice):
page, user = alice
create_post(page, "devlog", "Post for activity comment link")
post_url = page.url
_post_comment(page, "Activity clickable comment body")
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=activity",
wait_until="domcontentloaded",
)
card_link = page.locator(
".activity-card.card-link-host a.card-link[href*='#comment-']"
).first
expect(card_link).to_have_count(1)
href = card_link.get_attribute("href")
assert "/posts/" in href and "#comment-" in href
card_link.click()
page.wait_for_url("**/posts/**", wait_until="domcontentloaded")
def test_the_owner_sees_every_privacy_control(bob):
page, user = bob
_privacy_tab(page, user["username"])
panel = page.locator(".privacy-panel")
expect(panel.locator(f"form[action='/profile/{user['username']}/consent']").first).to_be_attached()
expect(
page.locator(".comment-text:has-text('Activity clickable comment body')")
panel.locator(f"form[action='/profile/{user['username']}/mature-content']")
).to_be_visible()
expect(panel.locator("a:has-text('Delete account')")).to_be_visible()
def test_activity_post_card_links_to_post(alice):
page, user = alice
create_post(page, "showcase", "Post for activity post link", title="Activity Post Title")
def test_an_admin_reads_the_state_but_gets_no_privacy_controls(alice, seeded_db):
admin_page, _ = alice
member = seeded_db["bob"]
_privacy_tab(admin_page, member["username"])
panel = admin_page.locator(".privacy-panel")
expect(panel).to_contain_text("Only the account holder can change this.")
expect(panel).to_contain_text("Only the account holder can change this preference.")
expect(panel.locator("form[action$='/consent']")).to_have_count(0)
expect(panel.locator("form[action$='/mature-content']")).to_have_count(0)
expect(panel.locator("a:has-text('Delete account')")).to_have_count(0)
def test_a_stranger_never_reaches_the_privacy_panel(bob):
page, _ = bob
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=activity",
wait_until="domcontentloaded",
f"{BASE_URL}/profile/alice_test?tab=privacy", wait_until="domcontentloaded"
)
post_link = page.locator(
".activity-card.card-link-host a.card-link[href^='/posts/']:not([href*='#comment-'])"
).first
expect(post_link).to_have_count(1)
post_link.click()
page.wait_for_url("**/posts/**", wait_until="domcontentloaded")
expect(page.locator(".post-detail-title:has-text('Activity Post Title')")).to_be_visible()
def test_activity_inner_content_link_stays_clickable(alice, bob):
page, user = alice
create_post(page, "random", "Post for activity mention comment")
_post_comment(page, "Ping @bob_test in activity")
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=activity",
wait_until="domcontentloaded",
)
mention = page.locator(
".activity-card.card-link-host .activity-content a[href='/profile/bob_test']"
).first
expect(mention).to_have_count(1)
mention.click()
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
def test_follow_then_unfollow(alice):
page, _ = alice
page.goto(f"{BASE_URL}/profile/bob_test", wait_until="domcontentloaded")
if page.is_visible(UNFOLLOW):
page.click(UNFOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(FOLLOW)
page.click(FOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(UNFOLLOW)
page.click(UNFOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(FOLLOW)
def test_profile_viewing_other_user(bob, alice):
page_b, user_b = bob
page_b.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
assert page_b.is_visible("text=alice_test")
def test_profile_message_button(bob, alice):
page_b, _ = bob
page_b.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
message_btn = page_b.locator("a:has-text('Send Message')")
assert message_btn.is_visible()
expect(page.locator(".privacy-panel")).to_have_count(0)
expect(page.locator("form[action='/profile/alice_test/consent']")).to_have_count(0)
+9
View File
@@ -18,6 +18,8 @@ def _signup_api_auth(password="secret123"):
"email": email,
"password": password,
"confirm_password": password,
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -67,6 +69,8 @@ def _register_session(prefix):
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -77,6 +81,7 @@ def _seed_gists(count):
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -142,6 +147,7 @@ def _seed_searchable_gists(language="python"):
{
"uid": owner,
"username": f"gsrch_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -189,6 +195,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,
)
@@ -320,6 +328,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
+2
View File
@@ -17,6 +17,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,
)
+1
View File
@@ -15,6 +15,7 @@ def _seed_project():
{
"uid": owner,
"username": f"dlowner_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@dl.test",
"password_hash": "x",
"role": "Member",
+4
View File
@@ -17,6 +17,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,
)
@@ -133,6 +135,8 @@ def _signup_zip_download():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+11
View File
@@ -19,6 +19,7 @@ def _seed_posts(count):
{
"uid": owner,
"username": f"pag_{suite}_{i}",
"terms_version": "1",
"email": f"{suite}_{i}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -51,6 +52,7 @@ def _seed_post_with_comments(comment_texts, topic="devlog"):
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
@@ -115,6 +117,7 @@ def _seed_searchable_posts():
{
"uid": owner,
"username": f"srch_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -155,6 +158,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,
)
@@ -256,6 +261,7 @@ def _seed_projects(count):
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
@@ -323,6 +329,8 @@ def _signup_zip_download():
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
@@ -871,6 +879,7 @@ def _seed_project_with_comments():
{
"uid": owner,
"username": f"pcseed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@pc.seed",
"password_hash": "x",
"role": "Member",
@@ -954,6 +963,8 @@ def _signup_containers_menu(prefix):
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+2
View File
@@ -26,6 +26,8 @@ def _seed_quiz(title="Hub e2e quiz"):
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
+2
View File
@@ -38,6 +38,8 @@ def _author_quiz(title, questions=(CHOICE, NUMERIC), **settings):
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
View File
+170
View File
@@ -0,0 +1,170 @@
# retoor <retoor@molodetz.nl>
from playwright.sync_api import expect
from devplacepy.database import get_table, refresh_snapshot
from tests.conftest import BASE_URL
def _create_post(page, content):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
return page.url.rsplit("/", 1)[-1]
def test_the_report_control_is_present_on_the_feed_and_a_post(alice, bob):
alice_page, _ = alice
bob_page, _ = bob
_create_post(alice_page, "A post that another member can report from the feed.")
bob_page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
expect(bob_page.locator(".report-btn").first).to_be_visible()
expect(bob_page.locator(".block-btn").first).to_be_attached()
def test_the_report_control_is_absent_on_your_own_content(alice):
page, _ = alice
slug = _create_post(page, "A post whose author must not see a report control.")
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
expect(page.locator(".post-detail-actions .report-btn")).to_have_count(0)
def test_reporting_a_post_end_to_end_through_the_dialog(alice, bob):
alice_page, _ = alice
bob_page, _ = bob
slug = _create_post(alice_page, "A post reported end to end through the dialog.")
bob_page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
bob_page.locator(".report-btn").first.click()
dialog = bob_page.locator("#report-dialog")
dialog.wait_for(state="visible")
bob_page.select_option("#report-reason", "harassment")
bob_page.fill("#report-detail", "Reported by the end-to-end test.")
bob_page.locator("#report-form button[type='submit']").click()
expect(bob_page.locator("dp-toast")).to_contain_text("Report received", timeout=8000)
bob_page.goto(f"{BASE_URL}/reports/mine", wait_until="domcontentloaded")
expect(bob_page.locator("table")).to_contain_text("Harassment")
def test_the_admin_queue_shows_the_report_and_its_sla_badge(alice, bob):
alice_page, _ = alice
bob_page, _ = bob
slug = _create_post(alice_page, "A post that must appear in the moderation queue.")
bob_page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
bob_page.locator(".report-btn").first.click()
bob_page.locator("#report-dialog").wait_for(state="visible")
bob_page.select_option("#report-reason", "spam")
bob_page.locator("#report-form button[type='submit']").click()
expect(bob_page.locator("dp-toast")).to_contain_text("Report received", timeout=8000)
alice_page.goto(f"{BASE_URL}/admin/moderation", wait_until="domcontentloaded")
expect(alice_page.locator(".sla-badge")).to_be_visible()
expect(alice_page.locator(".admin-table")).to_contain_text("Spam")
def test_the_report_dialog_lists_every_reason(alice):
from devplacepy.database import REPORT_REASONS
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
options = page.locator("#report-reason option")
assert options.count() == len(REPORT_REASONS)
def test_blocking_from_a_content_action_bar_hides_the_author(alice, bob):
alice_page, alice_user = alice
bob_page, _ = bob
marker = "A post that disappears from the feed after a block."
_create_post(alice_page, marker)
bob_page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
expect(bob_page.locator(".post-card").first).to_be_visible()
bob_page.locator(".block-btn").first.click()
bob_page.locator(".dialog-overlay.visible .dialog-confirm").click()
bob_page.wait_for_load_state("domcontentloaded")
bob_page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert marker not in bob_page.content()
bob_page.goto(
f"{BASE_URL}/profile/{alice_user['username']}", wait_until="domcontentloaded"
)
bob_page.locator("button:has-text('Unblock')").first.click()
bob_page.locator(".dialog-overlay.visible .dialog-confirm").click()
bob_page.wait_for_load_state("domcontentloaded")
def test_the_footer_links_every_legal_page(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
footer = page.locator(".footer-links")
for label in ("Terms", "Privacy", "Guidelines", "Contact"):
expect(footer.locator(f"a:has-text('{label}')")).to_be_visible()
def test_the_legal_pages_render_for_a_guest(page, app_server):
for slug in (
"terms",
"community-guidelines",
"privacy",
"contact",
"content-moderation",
"intellectual-property",
):
page.goto(f"{BASE_URL}/docs/{slug}.html", wait_until="domcontentloaded")
expect(page.locator("h1").first).to_be_visible()
def test_the_admin_operations_page_is_hidden_from_a_guest(page, app_server):
page.goto(
f"{BASE_URL}/docs/moderation-operations.html", wait_until="domcontentloaded"
)
assert "not found" in page.content().lower()
def test_the_admin_operations_page_renders_for_an_admin(alice):
alice_page, _ = alice
alice_page.goto(
f"{BASE_URL}/docs/moderation-operations.html", wait_until="domcontentloaded"
)
expect(alice_page.locator("h1").first).to_be_visible()
assert "App review checklist" in alice_page.content()
def test_deleting_an_account_through_the_ui_ends_the_login(page, app_server):
import time
name = f"e2edel{int(time.time() * 1000)}"
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", name)
page.fill("#email", f"{name}@t.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/profile/{name}/delete", wait_until="domcontentloaded")
page.fill("#delete-password", "secret123")
page.locator("button:has-text('Delete my account')").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/", wait_until="domcontentloaded")
refresh_snapshot()
assert get_table("users").find_one(username=name) is None
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", f"{name}@t.dev")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
page.wait_for_load_state("domcontentloaded")
assert "/feed" not in page.url
+2 -1
View File
@@ -41,6 +41,7 @@ def _seed_owner():
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
@@ -193,7 +194,7 @@ def test_landing_features(page, app_server):
features = [
"100% Free Forever",
"Zero Ads",
"No Censorship",
"No Gatekeeping",
"No Payments",
]
for feature in features: