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.
698 lines
23 KiB
Python
698 lines
23 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import requests
|
|
from tests.conftest import BASE_URL
|
|
def _seed_news_seo():
|
|
from datetime import datetime, timezone
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
uid = generate_uid()
|
|
title = f"SEO Test News Article {uid.split('-')[-1]}"
|
|
slug = make_combined_slug(title, uid)
|
|
get_table("news").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"slug": slug,
|
|
"title": title,
|
|
"description": "A seeded news article for SEO tests.",
|
|
"content": "Body content for the seeded article.",
|
|
"url": "https://example.com/article",
|
|
"source_name": "ExampleSource",
|
|
"status": "published",
|
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
|
"show_on_landing": 0,
|
|
"grade": 8,
|
|
}
|
|
)
|
|
return slug, uid
|
|
def _seed_owner():
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from devplacepy.database import get_table
|
|
|
|
uid = str(uuid4())
|
|
get_table("users").insert(
|
|
{
|
|
"uid": uid,
|
|
"username": f"seo_{uid[:8]}",
|
|
"terms_version": "1",
|
|
"email": f"{uid[:8]}@seo.test",
|
|
"password_hash": "x",
|
|
"role": "Member",
|
|
"is_active": True,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return uid
|
|
def _seed_post_seo(image=None):
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
owner = _seed_owner()
|
|
uid = str(uuid4())
|
|
slug = make_combined_slug("SEO Detail Post", uid)
|
|
get_table("posts").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": slug,
|
|
"title": "SEO Detail Post",
|
|
"content": "Body text for the SEO detail post.",
|
|
"topic": "general",
|
|
"project_uid": None,
|
|
"image": image,
|
|
"stars": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return slug, uid
|
|
def _seed_gist():
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
owner = _seed_owner()
|
|
uid = str(uuid4())
|
|
slug = make_combined_slug("SEO Detail Gist", uid)
|
|
get_table("gists").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": slug,
|
|
"title": "SEO Detail Gist",
|
|
"description": "Gist description for SEO tests.",
|
|
"source_code": "print('seo')",
|
|
"language": "python",
|
|
"stars": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return slug, uid
|
|
def _seed_project():
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
owner = _seed_owner()
|
|
uid = str(uuid4())
|
|
slug = make_combined_slug("SEO Detail Project", uid)
|
|
get_table("projects").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": slug,
|
|
"title": "SEO Detail Project",
|
|
"description": "Project description for SEO tests.",
|
|
"project_type": "software",
|
|
"platforms": "Linux",
|
|
"status": "Released",
|
|
"stars": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return slug, uid
|
|
def _seed_news_image(news_uid):
|
|
from devplacepy.database import get_table
|
|
|
|
get_table("news_images").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"news_uid": news_uid,
|
|
"url": "https://example.com/seo-news-image.jpg",
|
|
}
|
|
)
|
|
def _seed_feed_posts(count):
|
|
from datetime import datetime, timezone, timedelta
|
|
from uuid import uuid4
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
owner = _seed_owner()
|
|
topic = f"seopag{owner[:8]}"
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
posts = get_table("posts")
|
|
for i in range(count):
|
|
uid = str(uuid4())
|
|
posts.insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": make_combined_slug(f"seo pag {i}", uid),
|
|
"title": None,
|
|
"content": f"seo pag post {i}",
|
|
"topic": topic,
|
|
"project_uid": None,
|
|
"image": None,
|
|
"stars": 0,
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
}
|
|
)
|
|
return topic
|
|
|
|
|
|
def test_missing_profile_returns_404(app_server):
|
|
r = requests.get(f"{BASE_URL}/profile/no-such-user-xyz", allow_redirects=False)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def _seed_comment_on_post(commenter_uid):
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from devplacepy.database import get_table
|
|
|
|
post_slug, post_uid = _seed_post_seo()
|
|
comment_uid = str(uuid4())
|
|
get_table("comments").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": comment_uid,
|
|
"target_type": "post",
|
|
"target_uid": post_uid,
|
|
"post_uid": post_uid,
|
|
"user_uid": commenter_uid,
|
|
"content": "Activity tab link comment body",
|
|
"parent_uid": None,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return post_slug, post_uid, comment_uid
|
|
|
|
|
|
def test_activity_comment_exposes_parent_post_url(app_server):
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
commenter = _seed_owner()
|
|
username = get_table("users").find_one(uid=commenter)["username"]
|
|
post_slug, _post_uid, comment_uid = _seed_comment_on_post(commenter)
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}?tab=activity",
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert r.status_code == 200
|
|
activities = r.json()["activities"]
|
|
comment_acts = [a for a in activities if a.get("type") == "comment"]
|
|
assert comment_acts, "expected a comment activity"
|
|
act = comment_acts[0]
|
|
assert act["url"] == f"/posts/{post_slug}#comment-{comment_uid}"
|
|
assert act["target_type"] == "post"
|
|
|
|
|
|
def test_activity_comment_url_matches_notification_target(app_server):
|
|
from devplacepy.database import get_table, refresh_snapshot, resolve_object_url
|
|
|
|
commenter = _seed_owner()
|
|
username = get_table("users").find_one(uid=commenter)["username"]
|
|
_post_slug, post_uid, comment_uid = _seed_comment_on_post(commenter)
|
|
refresh_snapshot()
|
|
|
|
expected = f"{resolve_object_url('post', post_uid)}#comment-{comment_uid}"
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}?tab=activity",
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
act = [a for a in r.json()["activities"] if a.get("type") == "comment"][0]
|
|
assert act["url"] == expected
|
|
|
|
|
|
def test_activity_post_exposes_url(app_server):
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
owner = _seed_owner()
|
|
username = get_table("users").find_one(uid=owner)["username"]
|
|
post_slug, post_uid = _seed_post_seo()
|
|
get_table("posts").update({"uid": post_uid, "user_uid": owner}, ["uid"])
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}?tab=activity",
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert r.status_code == 200
|
|
post_acts = [a for a in r.json()["activities"] if a.get("type") == "post"]
|
|
assert post_acts, "expected a post activity"
|
|
assert post_acts[0]["url"] == f"/posts/{post_slug}"
|
|
|
|
|
|
def test_activity_comment_card_renders_overlay_link(app_server):
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
commenter = _seed_owner()
|
|
username = get_table("users").find_one(uid=commenter)["username"]
|
|
post_slug, _post_uid, comment_uid = _seed_comment_on_post(commenter)
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(f"{BASE_URL}/profile/{username}?tab=activity")
|
|
assert r.status_code == 200
|
|
assert "activity-card card-link-host" in r.text
|
|
assert f'class="card-link" href="/posts/{post_slug}#comment-{comment_uid}"' in r.text
|
|
|
|
|
|
def test_profile_json_exposes_online_presence(app_server):
|
|
import time
|
|
|
|
name = f"pres{int(time.time() * 1000)}"
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
r = session.get(
|
|
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["profile_online"] is True
|
|
assert body["profile_user"]["last_seen"]
|
|
|
|
|
|
def test_profile_json_offline_when_last_seen_stale(app_server):
|
|
from datetime import datetime, timezone, timedelta
|
|
from devplacepy.config import PRESENCE_TIMEOUT_SECONDS
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
owner = _seed_owner()
|
|
username = get_table("users").find_one(uid=owner)["username"]
|
|
stale = (
|
|
datetime.now(timezone.utc)
|
|
- timedelta(seconds=PRESENCE_TIMEOUT_SECONDS + 120)
|
|
).isoformat()
|
|
get_table("users").update({"uid": owner, "last_seen": stale}, ["uid"])
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["profile_online"] is False
|
|
assert body["profile_user"]["last_seen"] == stale
|
|
|
|
|
|
def test_profile_hero_avatar_has_presence_dot(app_server):
|
|
import time
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
name = f"phero{int(time.time() * 1000)}"
|
|
s = requests.Session()
|
|
s.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
refresh_snapshot()
|
|
uid = get_table("users").find_one(username=name)["uid"]
|
|
html = s.get(f"{BASE_URL}/profile/{name}").text
|
|
assert "profile-avatar-wrap" in html
|
|
assert "presence-dot" in html
|
|
assert f'data-presence-uid="{uid}"' in html
|
|
|
|
|
|
def test_followers_list_avatar_has_presence_dot(app_server):
|
|
import time
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
stamp = int(time.time() * 1000)
|
|
target = f"folt{stamp}"
|
|
follower = f"folf{stamp}"
|
|
st = requests.Session()
|
|
sf = requests.Session()
|
|
for sess, name in ((st, target), (sf, follower)):
|
|
sess.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
sf.post(f"{BASE_URL}/follow/{target}", allow_redirects=True)
|
|
refresh_snapshot()
|
|
follower_uid = get_table("users").find_one(username=follower)["uid"]
|
|
html = st.get(f"{BASE_URL}/profile/{target}?tab=followers").text
|
|
assert "follow-user" in html
|
|
assert f'data-presence-uid="{follower_uid}"' in html
|
|
assert "data-presence-last-seen" in html
|
|
|
|
|
|
def test_viewing_profile_marks_notification_read(app_server):
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
from devplacepy.utils import generate_uid
|
|
|
|
name = f"nmark{int(time.time() * 1000)}"
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
refresh_snapshot()
|
|
user = get_table("users").find_one(username=name)
|
|
notif_uid = generate_uid()
|
|
get_table("notifications").insert(
|
|
{
|
|
"uid": notif_uid,
|
|
"user_uid": user["uid"],
|
|
"type": "follow",
|
|
"message": "someone followed you",
|
|
"related_uid": generate_uid(),
|
|
"target_url": f"/profile/{name}",
|
|
"read": False,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
refresh_snapshot()
|
|
|
|
r = session.get(f"{BASE_URL}/profile/{name}", allow_redirects=True)
|
|
assert r.status_code == 200
|
|
|
|
refresh_snapshot()
|
|
assert bool(get_table("notifications").find_one(uid=notif_uid)["read"]) is True
|
|
|
|
|
|
def test_own_profile_json_exposes_xp_fields(app_server, seeded_db):
|
|
"""GET /profile (own) with Accept: application/json includes xp_next_level and xp_progress_pct."""
|
|
import requests
|
|
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/login",
|
|
data={
|
|
"email": seeded_db["alice"]["email"],
|
|
"password": seeded_db["alice"]["password"],
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
r = session.get(f"{BASE_URL}/profile", headers={"Accept": "application/json"})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
pu = data["profile_user"]
|
|
assert "xp_next_level" in pu, "xp_next_level missing from own profile JSON"
|
|
assert "xp_progress_pct" in pu, "xp_progress_pct missing from own profile JSON"
|
|
assert isinstance(pu["xp_next_level"], int)
|
|
assert isinstance(pu["xp_progress_pct"], int)
|
|
|
|
|
|
def test_other_profile_json_exposes_xp_fields(app_server):
|
|
"""GET /profile/{username} with Accept: application/json includes xp_next_level and xp_progress_pct."""
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
owner = _seed_owner()
|
|
username = get_table("users").find_one(uid=owner)["username"]
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
pu = data["profile_user"]
|
|
assert "xp_next_level" in pu, "xp_next_level missing from other profile JSON"
|
|
assert "xp_progress_pct" in pu, "xp_progress_pct missing from other profile JSON"
|
|
assert isinstance(pu["xp_next_level"], int)
|
|
assert isinstance(pu["xp_progress_pct"], int)
|
|
|
|
|
|
def test_profile_json_xp_fields_zero_xp(app_server):
|
|
"""User with 0 XP returns xp_next_level=100 (level 1) and xp_progress_pct=0."""
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
from devplacepy.utils.rewards import LEVEL_XP
|
|
|
|
owner = _seed_owner()
|
|
username = get_table("users").find_one(uid=owner)["username"]
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
pu = r.json()["profile_user"]
|
|
assert pu["xp"] == 0 or pu["xp"] is None, f"expected 0 xp, got {pu['xp']}"
|
|
assert pu["xp_next_level"] == LEVEL_XP, (
|
|
f"expected xp_next_level={LEVEL_XP} for level 1, got {pu['xp_next_level']}"
|
|
)
|
|
assert pu["xp_progress_pct"] == 0, (
|
|
f"expected xp_progress_pct=0 for 0 XP, got {pu['xp_progress_pct']}"
|
|
)
|
|
|
|
|
|
def test_profile_json_xp_fields_boundary_xp(app_server):
|
|
"""User with exactly 100 XP (level 2) returns xp_next_level=200 and xp_progress_pct=0."""
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
from devplacepy.utils.rewards import award_xp
|
|
|
|
owner = _seed_owner()
|
|
username = get_table("users").find_one(uid=owner)["username"]
|
|
award_xp(owner, 100)
|
|
refresh_snapshot()
|
|
|
|
r = requests.get(
|
|
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
pu = r.json()["profile_user"]
|
|
assert pu["xp"] == 100, f"expected 100 xp, got {pu['xp']}"
|
|
assert pu["level"] == 2, f"expected level 2, got {pu['level']}"
|
|
assert pu["xp_next_level"] == 200, (
|
|
f"expected xp_next_level=200 for level 2, got {pu['xp_next_level']}"
|
|
)
|
|
assert pu["xp_progress_pct"] == 0, (
|
|
f"expected xp_progress_pct=0 at boundary, got {pu['xp_progress_pct']}"
|
|
)
|
|
|
|
|
|
def _signup_badge_user():
|
|
import time
|
|
|
|
name = f"bdgi{int(time.time() * 1000)}"
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
return session, name
|
|
|
|
|
|
def test_profile_badges_json_description_matches_catalog(app_server):
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
from devplacepy.utils import BADGE_CATALOG, award_badge
|
|
|
|
session, name = _signup_badge_user()
|
|
refresh_snapshot()
|
|
user = get_table("users").find_one(username=name)
|
|
assert user, f"user {name} not found after signup"
|
|
award_badge(user["uid"], "First Post")
|
|
award_badge(user["uid"], "Member")
|
|
refresh_snapshot()
|
|
|
|
r = session.get(
|
|
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert "badges" in body, "badges key missing from profile JSON"
|
|
assert isinstance(body["badges"], list), "badges is not a list"
|
|
assert body["badges"], "expected at least one badge"
|
|
for badge in body["badges"]:
|
|
assert "description" in badge, f"badge missing description key: {badge}"
|
|
assert badge["description"] is not None, f"badge description is null: {badge}"
|
|
assert isinstance(badge["description"], str), (
|
|
f"badge description is not a string: {badge}"
|
|
)
|
|
assert badge["description"] == BADGE_CATALOG[badge["name"]]["description"], (
|
|
f"badge description mismatch: {badge['name']}"
|
|
)
|
|
|
|
|
|
def test_profile_badge_description_exact_string(app_server):
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
from devplacepy.utils import award_badge
|
|
|
|
session, name = _signup_badge_user()
|
|
refresh_snapshot()
|
|
user = get_table("users").find_one(username=name)
|
|
assert user, f"user {name} not found after signup"
|
|
award_badge(user["uid"], "Cheerleader")
|
|
refresh_snapshot()
|
|
|
|
r = session.get(
|
|
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
|
|
)
|
|
assert r.status_code == 200
|
|
badges = r.json()["badges"]
|
|
cheerleader = [b for b in badges if b["name"] == "Cheerleader"]
|
|
assert cheerleader, f"Cheerleader badge missing from profile JSON: {badges}"
|
|
assert cheerleader[0]["description"] == "Reacted 50 times", (
|
|
f"expected 'Reacted 50 times', got {cheerleader[0]['description']!r}"
|
|
)
|
|
|
|
|
|
PRIVACY_FIELDS = (
|
|
"age_band",
|
|
"terms_version",
|
|
"terms_accepted_at",
|
|
"suspended_until",
|
|
"suspension_reason",
|
|
)
|
|
|
|
|
|
def _privacy_member(prefix="priv"):
|
|
import time
|
|
|
|
name = f"{prefix}{int(time.time() * 1000000)}"
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
return session, name
|
|
|
|
|
|
def _admin_session():
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/login",
|
|
data={"email": "alice@test.devplace", "password": "secret123"},
|
|
allow_redirects=True,
|
|
)
|
|
return session
|
|
|
|
|
|
def test_the_owner_reads_their_own_privacy_state(app_server):
|
|
session, name = _privacy_member()
|
|
body = session.get(
|
|
f"{BASE_URL}/profile/{name}?tab=privacy",
|
|
headers={"Accept": "application/json"},
|
|
).json()
|
|
assert body["age_band"] == "adult"
|
|
assert body["terms_version"]
|
|
assert body["terms_accepted_at"]
|
|
assert body["consents"]
|
|
|
|
|
|
def test_privacy_state_is_withheld_from_a_stranger(app_server):
|
|
_, name = _privacy_member()
|
|
stranger, _ = _privacy_member("prvs")
|
|
body = stranger.get(
|
|
f"{BASE_URL}/profile/{name}?tab=privacy",
|
|
headers={"Accept": "application/json"},
|
|
).json()
|
|
for field in PRIVACY_FIELDS:
|
|
assert body[field] in ("", None), f"{field} leaked to a stranger: {body[field]!r}"
|
|
assert body["mature_opt_in"] is False
|
|
assert body["consents"] == []
|
|
|
|
|
|
def test_privacy_state_is_withheld_from_a_guest(app_server):
|
|
_, name = _privacy_member()
|
|
body = requests.get(
|
|
f"{BASE_URL}/profile/{name}?tab=privacy",
|
|
headers={"Accept": "application/json"},
|
|
).json()
|
|
for field in PRIVACY_FIELDS:
|
|
assert body[field] in ("", None), f"{field} leaked to a guest: {body[field]!r}"
|
|
assert body["consents"] == []
|
|
|
|
|
|
def test_a_strangers_profile_html_never_renders_the_privacy_panel(app_server):
|
|
_, name = _privacy_member()
|
|
stranger, _ = _privacy_member("prvh")
|
|
html = stranger.get(f"{BASE_URL}/profile/{name}?tab=privacy").text
|
|
assert "privacy-panel" not in html
|
|
assert f"/profile/{name}/consent" not in html
|
|
assert f"/profile/{name}/delete" not in html
|
|
|
|
|
|
def test_an_admin_reads_the_state_but_gets_no_controls(app_server, seeded_db):
|
|
_, name = _privacy_member()
|
|
admin = _admin_session()
|
|
body = admin.get(
|
|
f"{BASE_URL}/profile/{name}?tab=privacy",
|
|
headers={"Accept": "application/json"},
|
|
).json()
|
|
assert body["age_band"] == "adult"
|
|
assert body["consents"]
|
|
|
|
html = admin.get(f"{BASE_URL}/profile/{name}?tab=privacy").text
|
|
assert "privacy-panel" in html
|
|
assert "Only the account holder can change this." in html
|
|
assert "Only the account holder can change this preference." in html
|
|
assert f'action="/profile/{name}/consent"' not in html
|
|
assert f'action="/profile/{name}/mature-content"' not in html
|
|
assert f"/profile/{name}/delete" not in html
|
|
|
|
|
|
def test_the_owner_gets_the_privacy_controls(app_server):
|
|
session, name = _privacy_member()
|
|
html = session.get(f"{BASE_URL}/profile/{name}?tab=privacy").text
|
|
assert f'action="/profile/{name}/consent"' in html
|
|
assert f'action="/profile/{name}/mature-content"' in html
|
|
assert f"/profile/{name}/delete" in html
|
|
assert "Only the account holder can change this." not in html
|
|
|
|
|
|
|