Issue #2: The profile route used a custom role-based check instead of the permission-based pattern used everywhere else. Changes: - Add MANAGE_OWN_PROFILE permission to backend Permission enum - Add permission to ROLE_REGULAR role definition - Update profile routes to use require_permission(MANAGE_OWN_PROFILE) - Remove custom require_regular_user dependency - Update frontend Permission constant and profile page - Update invites page to use permission instead of role check - Update profile tests with proper permission mocking This ensures consistent authorization patterns across all routes.
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""Tests for user profile and contact details."""
|
|
|
|
from sqlalchemy import select
|
|
|
|
from auth import get_password_hash
|
|
from models import User
|
|
from tests.helpers import unique_email
|
|
|
|
# Valid npub for testing (32 zero bytes encoded as bech32)
|
|
VALID_NPUB = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme"
|
|
|
|
|
|
class TestUserContactFields:
|
|
"""Test that contact fields work correctly on the User model."""
|
|
|
|
async def test_contact_fields_default_to_none(self, client_factory):
|
|
"""New users should have all contact fields as None."""
|
|
email = unique_email("test")
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
user = User(
|
|
email=email,
|
|
hashed_password=get_password_hash("password123"),
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
|
|
assert user.contact_email is None
|
|
assert user.telegram is None
|
|
assert user.signal is None
|
|
assert user.nostr_npub is None
|
|
|
|
async def test_contact_fields_can_be_set(self, client_factory):
|
|
"""Contact fields can be set when creating a user."""
|
|
email = unique_email("test")
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
user = User(
|
|
email=email,
|
|
hashed_password=get_password_hash("password123"),
|
|
contact_email="contact@example.com",
|
|
telegram="@alice",
|
|
signal="alice.42",
|
|
nostr_npub="npub1test",
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
|
|
assert user.contact_email == "contact@example.com"
|
|
assert user.telegram == "@alice"
|
|
assert user.signal == "alice.42"
|
|
assert user.nostr_npub == "npub1test"
|
|
|
|
async def test_contact_fields_persist_after_reload(self, client_factory):
|
|
"""Contact fields should persist in the database."""
|
|
email = unique_email("test")
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
user = User(
|
|
email=email,
|
|
hashed_password=get_password_hash("password123"),
|
|
contact_email="contact@example.com",
|
|
telegram="@bob",
|
|
signal="bob.99",
|
|
nostr_npub="npub1xyz",
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
user_id = user.id
|
|
|
|
# Reload from database in a new session
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
loaded_user = result.scalar_one()
|
|
|
|
assert loaded_user.contact_email == "contact@example.com"
|
|
assert loaded_user.telegram == "@bob"
|
|
assert loaded_user.signal == "bob.99"
|
|
assert loaded_user.nostr_npub == "npub1xyz"
|
|
|
|
async def test_contact_fields_can_be_updated(self, client_factory):
|
|
"""Contact fields can be updated after user creation."""
|
|
email = unique_email("test")
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
user = User(
|
|
email=email,
|
|
hashed_password=get_password_hash("password123"),
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
user_id = user.id
|
|
|
|
# Update fields
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one()
|
|
|
|
user.contact_email = "new@example.com"
|
|
user.telegram = "@updated"
|
|
await db.commit()
|
|
|
|
# Verify update persisted
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one()
|
|
|
|
assert user.contact_email == "new@example.com"
|
|
assert user.telegram == "@updated"
|
|
assert user.signal is None # Still None
|
|
assert user.nostr_npub is None # Still None
|
|
|
|
async def test_contact_fields_can_be_cleared(self, client_factory):
|
|
"""Contact fields can be set back to None."""
|
|
email = unique_email("test")
|
|
|
|
async with client_factory.get_db_session() as db:
|
|
user = User(
|
|
email=email,
|
|
hashed_password=get_password_hash("password123"),
|
|
contact_email="contact@example.com",
|
|
telegram="@alice",
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
user_id = user.id
|
|
|
|
# Clear fields
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one()
|
|
|
|
user.contact_email = None
|
|
user.telegram = None
|
|
await db.commit()
|
|
|
|
# Verify cleared
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one()
|
|
|
|
assert user.contact_email is None
|
|
assert user.telegram is None
|
|
|
|
|
|
class TestGetProfileEndpoint:
|
|
"""Tests for GET /api/profile endpoint."""
|
|
|
|
async def test_regular_user_can_get_profile(self, client_factory, regular_user):
|
|
"""Regular user can fetch their profile."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "contact_email" in data
|
|
assert "telegram" in data
|
|
assert "signal" in data
|
|
assert "nostr_npub" in data
|
|
# All should be None for new user
|
|
assert data["contact_email"] is None
|
|
assert data["telegram"] is None
|
|
assert data["signal"] is None
|
|
assert data["nostr_npub"] is None
|
|
|
|
async def test_admin_user_cannot_access_profile(self, client_factory, admin_user):
|
|
"""Admin user gets 403 when trying to access profile (lacks MANAGE_OWN_PROFILE)."""
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 403
|
|
assert "manage_own_profile" in response.json()["detail"].lower()
|
|
|
|
async def test_unauthenticated_user_gets_401(self, client_factory):
|
|
"""Unauthenticated user gets 401."""
|
|
async with client_factory.create() as client:
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_profile_returns_existing_data(self, client_factory, regular_user):
|
|
"""Profile returns data that was previously set."""
|
|
# Set some data directly in DB
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(
|
|
select(User).where(User.email == regular_user["email"])
|
|
)
|
|
user = result.scalar_one()
|
|
user.contact_email = "contact@test.com"
|
|
user.telegram = "@testuser"
|
|
await db.commit()
|
|
|
|
# Fetch via API
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["contact_email"] == "contact@test.com"
|
|
assert data["telegram"] == "@testuser"
|
|
assert data["signal"] is None
|
|
assert data["nostr_npub"] is None
|
|
|
|
|
|
class TestUpdateProfileEndpoint:
|
|
"""Tests for PUT /api/profile endpoint."""
|
|
|
|
async def test_regular_user_can_update_profile(self, client_factory, regular_user):
|
|
"""Regular user can update their profile."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": "new@example.com",
|
|
"telegram": "@newhandle",
|
|
"signal": "signal.42",
|
|
"nostr_npub": VALID_NPUB,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["contact_email"] == "new@example.com"
|
|
assert data["telegram"] == "@newhandle"
|
|
assert data["signal"] == "signal.42"
|
|
assert data["nostr_npub"] == VALID_NPUB
|
|
|
|
async def test_profile_update_persists(self, client_factory, regular_user):
|
|
"""Profile updates persist in the database."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
await client.put(
|
|
"/api/profile",
|
|
json={"telegram": "@persisted"},
|
|
)
|
|
|
|
# Fetch again to verify
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["telegram"] == "@persisted"
|
|
|
|
async def test_admin_user_cannot_update_profile(self, client_factory, admin_user):
|
|
"""Admin user gets 403 when trying to update profile."""
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={"telegram": "@admin"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
async def test_unauthenticated_user_gets_401(self, client_factory):
|
|
"""Unauthenticated user gets 401."""
|
|
async with client_factory.create() as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={"telegram": "@test"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_can_clear_fields(self, client_factory, regular_user):
|
|
"""User can clear fields by setting them to null."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
# First set some values
|
|
await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": "test@example.com",
|
|
"telegram": "@test",
|
|
},
|
|
)
|
|
|
|
# Then clear them
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": None,
|
|
"telegram": None,
|
|
"signal": None,
|
|
"nostr_npub": None,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["contact_email"] is None
|
|
assert data["telegram"] is None
|
|
|
|
async def test_invalid_email_returns_422(self, client_factory, regular_user):
|
|
"""Invalid email format returns 422 with field error."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={"contact_email": "not-an-email"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "field_errors" in data["detail"]
|
|
assert "contact_email" in data["detail"]["field_errors"]
|
|
|
|
async def test_invalid_telegram_returns_422(self, client_factory, regular_user):
|
|
"""Invalid telegram handle returns 422 with field error."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={"telegram": "missing_at_sign"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "field_errors" in data["detail"]
|
|
assert "telegram" in data["detail"]["field_errors"]
|
|
|
|
async def test_invalid_npub_returns_422(self, client_factory, regular_user):
|
|
"""Invalid nostr npub returns 422 with field error."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={"nostr_npub": "npub1invalid"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "field_errors" in data["detail"]
|
|
assert "nostr_npub" in data["detail"]["field_errors"]
|
|
|
|
async def test_multiple_invalid_fields_returns_all_errors(
|
|
self, client_factory, regular_user
|
|
):
|
|
"""Multiple invalid fields return all errors."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": "bad-email",
|
|
"telegram": "no-at",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "contact_email" in data["detail"]["field_errors"]
|
|
assert "telegram" in data["detail"]["field_errors"]
|
|
|
|
async def test_partial_update_preserves_other_fields(
|
|
self, client_factory, regular_user
|
|
):
|
|
"""Updating one field doesn't affect others (they get set to the request values)."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
# Set initial values
|
|
await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": "initial@example.com",
|
|
"telegram": "@initial",
|
|
},
|
|
)
|
|
|
|
# Update only telegram, but note: PUT replaces all fields
|
|
# So we need to include all fields we want to keep
|
|
response = await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": "initial@example.com",
|
|
"telegram": "@updated",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["contact_email"] == "initial@example.com"
|
|
assert data["telegram"] == "@updated"
|
|
|
|
|
|
class TestProfilePrivacy:
|
|
"""Tests to ensure profile data is private."""
|
|
|
|
async def test_profile_not_in_auth_me(self, client_factory, regular_user):
|
|
"""Contact details should NOT appear in /api/auth/me response."""
|
|
# First set some profile data
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
await client.put(
|
|
"/api/profile",
|
|
json={
|
|
"contact_email": "secret@example.com",
|
|
"telegram": "@secret",
|
|
},
|
|
)
|
|
|
|
# Check /api/auth/me doesn't expose it
|
|
response = await client.get("/api/auth/me")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# These fields should NOT be in the response
|
|
assert "contact_email" not in data
|
|
assert "telegram" not in data
|
|
assert "signal" not in data
|
|
assert "nostr_npub" not in data
|
|
|
|
|
|
class TestProfileGodfather:
|
|
"""Tests for godfather information in profile."""
|
|
|
|
async def test_profile_shows_godfather_email(
|
|
self, client_factory, admin_user, regular_user
|
|
):
|
|
"""Profile shows godfather email for users who signed up with invite."""
|
|
from sqlalchemy import select
|
|
|
|
from models import User
|
|
from tests.helpers import unique_email
|
|
|
|
# Create invite
|
|
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
|
async with client_factory.get_db_session() as db:
|
|
result = await db.execute(
|
|
select(User).where(User.email == regular_user["email"])
|
|
)
|
|
godfather = result.scalar_one()
|
|
|
|
create_resp = await client.post(
|
|
"/api/admin/invites",
|
|
json={"godfather_id": godfather.id},
|
|
)
|
|
identifier = create_resp.json()["identifier"]
|
|
|
|
# Register new user with invite
|
|
new_email = unique_email("godchild")
|
|
async with client_factory.create() as client:
|
|
reg_resp = await client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"email": new_email,
|
|
"password": "password123",
|
|
"invite_identifier": identifier,
|
|
},
|
|
)
|
|
new_user_cookies = dict(reg_resp.cookies)
|
|
|
|
# Check profile shows godfather
|
|
async with client_factory.create(cookies=new_user_cookies) as client:
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["godfather_email"] == regular_user["email"]
|
|
|
|
async def test_profile_godfather_null_for_seeded_users(
|
|
self, client_factory, regular_user
|
|
):
|
|
"""Profile shows null godfather for users without one (e.g., seeded users)."""
|
|
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
|
response = await client.get("/api/profile")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["godfather_email"] is None
|