Files
daddy 0b525d6996 Add public profile view endpoint: favorites, friends, privacy enforcement (US-3)
- Implemented GET /api/profile/{user_id} in backend/app/routers/profiles.py
- Server-side privacy enforcement for profile_visibility, favorites_visibility, and friends_list_visibility
- Block relationships enforce restricted view (no bio, empty favorites, empty friends)
- Non-friends restricted when profile_visibility or sections set to friends-only
- Asset public check for avatar in restricted view
- No private user fields leaked (email, password excluded)
- Added 7 integration tests in backend/tests/test_profile.py covering all privacy and block permutations

Independently verified: 26/26 profile tests passing.
2026-09-15 17:18:28 +00:00

407 lines
16 KiB
Python

"""Integration tests for /api/profile/me and /api/profile/me/favorites.
Hits the real app against the configured Postgres (same contract as the
other integration tests -- migrations applied first).
"""
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.profile import DEFAULT_PRIVACY
def _auth_headers(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
def _me_id(client: TestClient, headers: dict) -> int:
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _make_world(client: TestClient, headers: dict, name: str = "W") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": name, "visibility": "public"})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_asset(client: TestClient, headers: dict, name: str = "A") -> int:
"""Insert an asset row directly via raw SQL.
Not going through POST /api/assets/, and not through the Asset ORM
model, deliberately: Asset.asset_type's ENUM column has no
values_callable, so SQLAlchemy sends the Python enum *name* (e.g.
"IMAGE") to Postgres instead of the lowercase value the DB enum
actually stores ("image"), and both the router and a plain ORM insert
500/fail on it. That's a pre-existing bug unrelated to this task; these
tests only need a real asset row to reference by id, so insert with raw
SQL to sidestep the buggy enum type entirely.
"""
owner_id = _me_id(client, headers)
async def _create() -> int:
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
async with engine.begin() as conn:
result = await conn.execute(
text(
"INSERT INTO assets (name, asset_type, file_path, owner_id, is_public) "
"VALUES (:name, 'image', '', :owner_id, false) RETURNING id"
),
{"name": name, "owner_id": owner_id},
)
asset_id = result.scalar_one()
await engine.dispose()
return asset_id
return asyncio.run(_create())
class TestGetProfile:
def test_new_user_gets_the_default(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_default")
resp = client.get("/api/profile/me", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["bio"] is None
assert body["avatar_asset_id"] is None
assert body["privacy"] == DEFAULT_PRIVACY
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me").status_code == 401
class TestPutProfile:
def test_save_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_save")
asset_id = _make_asset(client, headers)
put = client.put(
"/api/profile/me", headers=headers,
json={"bio": "hello world", "avatar_asset_id": asset_id,
"privacy": {"profile_visibility": "friends"}},
)
assert put.status_code == 200, put.text
body = put.json()
assert body["is_default"] is False
assert body["bio"] == "hello world"
assert body["avatar_asset_id"] == asset_id
assert body["privacy"]["profile_visibility"] == "friends"
# unspecified privacy keys keep their default
assert body["privacy"]["friends_list_visibility"] == "public"
got = client.get("/api/profile/me", headers=headers)
assert got.status_code == 200
assert got.json() == body
def test_partial_update_preserves_other_fields(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_partial")
client.put("/api/profile/me", headers=headers, json={"bio": "first bio"})
put2 = client.put("/api/profile/me", headers=headers, json={"privacy": {"favorites_visibility": "private"}})
assert put2.status_code == 200, put2.text
body = put2.json()
assert body["bio"] == "first bio"
assert body["privacy"]["favorites_visibility"] == "private"
def test_other_user_cannot_edit_my_profile(self):
"""There is no user_id-scoped edit route at all -- PUT /me always
acts on the caller's own JWT identity, so another user's token can
never touch this user's profile."""
client = TestClient(app)
headers_a = _auth_headers(client, "pf_owner")
headers_b = _auth_headers(client, "pf_other")
client.put("/api/profile/me", headers=headers_a, json={"bio": "owner's bio"})
client.put("/api/profile/me", headers=headers_b, json={"bio": "other's bio"})
got_a = client.get("/api/profile/me", headers=headers_a).json()
got_b = client.get("/api/profile/me", headers=headers_b).json()
assert got_a["bio"] == "owner's bio"
assert got_b["bio"] == "other's bio"
assert got_a["user_id"] != got_b["user_id"]
def test_rejects_invalid_avatar_asset_id(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badasset")
resp = client.put("/api/profile/me", headers=headers, json={"avatar_asset_id": 999999})
assert resp.status_code == 422
def test_rejects_over_length_bio(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_longbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 501})
assert resp.status_code == 422
def test_accepts_bio_at_max_length(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_maxbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 500})
assert resp.status_code == 200, resp.text
def test_rejects_unknown_privacy_key(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivkey")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"nonsense_key": "public"}})
assert resp.status_code == 422
def test_rejects_invalid_privacy_value(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivval")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"profile_visibility": "nope"}})
assert resp.status_code == 422
def test_requires_auth(self):
client = TestClient(app)
resp = client.put("/api/profile/me", json={"bio": "x"})
assert resp.status_code == 401
class TestFavorites:
def test_new_user_has_no_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_default")
resp = client.get("/api/profile/me/favorites", headers=headers)
assert resp.status_code == 200
assert resp.json() == {"world_ids": []}
def test_replace_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_save")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
put = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, w2]})
assert put.status_code == 200, put.text
assert set(put.json()["world_ids"]) == {w1, w2}
got = client.get("/api/profile/me/favorites", headers=headers)
assert set(got.json()["world_ids"]) == {w1, w2}
def test_replace_is_a_full_replace_not_append(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_replace")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
second = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w2]})
assert second.json()["world_ids"] == [w2]
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == [w2]
def test_replace_with_empty_list_clears_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_clear")
w1 = _make_world(client, headers, "W1")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
cleared = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": []})
assert cleared.status_code == 200
assert cleared.json() == {"world_ids": []}
def test_rejects_nonexistent_world_id(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_badworld")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [999999]})
assert resp.status_code == 422
def test_rejects_nonexistent_world_id_mixed_with_valid(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_mixed")
w1 = _make_world(client, headers, "W1")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, 999999]})
assert resp.status_code == 422
# nothing partially applied
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == []
def test_favorites_isolated_per_user(self):
client = TestClient(app)
headers_a = _auth_headers(client, "fav_owner")
headers_b = _auth_headers(client, "fav_other")
w1 = _make_world(client, headers_a, "W1")
client.put("/api/profile/me/favorites", headers=headers_a, json={"world_ids": [w1]})
got_b = client.get("/api/profile/me/favorites", headers=headers_b)
assert got_b.json()["world_ids"] == []
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me/favorites").status_code == 401
assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401
class TestPublicProfile:
def test_public_profile_full_view(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_alice")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_bob")
w1 = _make_world(client, h_alice, "AliceWorld")
asset_id = _make_asset(client, h_alice, "AliceAsset")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id},
)
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["user_id"] == alice_id
assert data["username"] == "pub_alice"
assert data["bio"] == "Hi, I am Alice"
assert data["avatar_asset_id"] == asset_id
assert data["is_restricted"] is False
assert len(data["favorites"]) == 1
assert data["favorites"][0]["id"] == w1
assert data["favorites"][0]["name"] == "AliceWorld"
assert "email" not in data
assert "password" not in data
def test_public_profile_not_found(self):
client = TestClient(app)
h_bob = _auth_headers(client, "pub_nf_bob")
resp = client.get("/api/profile/999999", headers=h_bob)
assert resp.status_code == 404
def test_public_profile_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/1").status_code == 401
def test_public_profile_private_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_priv_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_priv_b")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}},
)
resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_bob.status_code == 200
data_bob = resp_bob.json()
assert data_bob["is_restricted"] is True
assert data_bob["bio"] is None
assert data_bob["favorites"] == []
assert data_bob["friends"] == []
# Alice viewing herself is not restricted
resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is False
assert resp_alice.json()["bio"] == "Top secret bio"
def test_public_profile_friends_only_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_fo_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_fo_b")
bob_id = _me_id(client, h_bob)
h_charlie = _auth_headers(client, "pub_fo_c")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}},
)
# Charlie is not a friend -> restricted
resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie)
assert resp_c.json()["is_restricted"] is True
assert resp_c.json()["bio"] is None
# Bob becomes friends with Alice
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
# Now Bob sees full profile and friends list contains Bob
resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_b.json()["is_restricted"] is False
assert resp_b.json()["bio"] == "Friends only bio"
assert any(f["id"] == bob_id for f in resp_b.json()["friends"])
def test_public_profile_blocked_user_gets_restricted(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_blk_a")
alice_id = _me_id(client, h_alice)
h_dan = _auth_headers(client, "pub_blk_d")
dan_id = _me_id(client, h_dan)
client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"})
client.post(f"/api/friends/{dan_id}/block", headers=h_alice)
# Dan viewing Alice gets restricted view
resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan)
assert resp_dan.status_code == 200
assert resp_dan.json()["is_restricted"] is True
assert resp_dan.json()["bio"] is None
# Alice viewing Dan gets restricted view
resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is True
def test_public_profile_selective_favorites_and_friends_privacy(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_sec_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_sec_b")
bob_id = _me_id(client, h_bob)
w1 = _make_world(client, h_alice, "WorldSec")
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
client.put(
"/api/profile/me",
headers=h_alice,
json={
"bio": "Bio is public",
"privacy": {
"profile_visibility": "public",
"favorites_visibility": "private",
"friends_list_visibility": "private",
},
},
)
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200
data = resp.json()
assert data["bio"] == "Bio is public"
assert data["favorites"] == []
assert data["friends"] == []