Phase 2: private rooms, room roles, and invites (backend only)

Adds room_invites table + migration, owner/admin/member role enforcement
(require_room_role), and endpoints for private room creation, room
management (update/delete/leave/transfer-ownership/change-role/remove-member),
and the invite lifecycle (create/list/accept/decline/revoke). Registration
stays invite-only via the CLI from Phase 1 — this is a separate, room-level
invite system for adding existing users to private rooms.

Frontend is untouched: the UI redesign is happening separately, so this phase
is backend + tests only (35 passing). Verified no regressions in the Phase 1
open-room/WebSocket flow via manual smoke test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 20:22:15 -06:00
co-authored by Claude Sonnet 5
parent 99aa029c0d
commit c79e96dd48
16 changed files with 1172 additions and 32 deletions
+13 -1
View File
@@ -42,7 +42,13 @@ async def db_session():
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.connect() as conn:
await conn.begin()
session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint")
# expire_on_commit=False matches app/database.py's production session
# factory -- without it, objects loaded earlier in a request (e.g.
# current_user) go stale after any service-layer commit and touching
# them raises MissingGreenlet on the next sync attribute access.
session = AsyncSession(
bind=conn, join_transaction_mode="create_savepoint", expire_on_commit=False
)
yield session
await session.close()
await conn.rollback()
@@ -106,6 +112,12 @@ async def register_and_login(
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
await register_user(db_session, data)
return await login_as(client, username, password)
async def login_as(client: AsyncClient, username: str, password: str = "password123"):
# Switch the shared `client`'s session cookie to an already-created user,
# without trying to register them again.
resp = await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": password},
+166
View File
@@ -0,0 +1,166 @@
import uuid
from datetime import datetime, timedelta, timezone
from app.models import RoomInvite
from tests.conftest import login_as, register_and_login
async def _create_private_room(client, name="secret"):
resp = await client.post("/api/rooms", json={"name": name, "is_private": True})
assert resp.status_code == 201, resp.text
return resp.json()
async def test_create_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await register_and_login(client, db_session, username="carol")
# bob has no membership in the room at all, so he's blocked by the
# membership check before role is even considered.
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "carol"}
)
assert resp.status_code == 403
async def test_invite_unknown_username_404(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "nobody"}
)
assert resp.status_code == 404
async def test_invite_accept_flow(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob") # seed bob's account only
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp.status_code == 201
invite = resp.json()
assert invite["status"] == "pending"
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.get("/api/invites/mine")
assert resp.status_code == 200
mine = resp.json()
assert len(mine) == 1
assert mine[0]["id"] == invite["id"]
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 200
assert resp.json()["role"] == "member"
resp = await client.get(f"/api/rooms/{room['id']}/messages")
assert resp.status_code == 200 # now a member
resp = await client.get("/api/rooms/mine")
assert any(r["name"] == room["name"] for r in resp.json())
async def test_accept_invite_wrong_user_403(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="carol")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 403
async def test_decline_invite(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/decline")
assert resp.status_code == 200
assert resp.json()["status"] == "revoked"
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400 # no longer pending
async def test_revoke_invite(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
resp = await client.delete(f"/api/rooms/{room['id']}/invites/{invite['id']}")
assert resp.status_code == 204
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400
async def test_duplicate_pending_invite_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
resp1 = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp1.status_code == 201
resp2 = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp2.status_code == 409
async def test_invite_already_member_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client, name="open-ish")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "alice"}
)
assert resp.status_code == 409
async def test_expired_invite_rejected_on_accept(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
db_invite = await db_session.get(RoomInvite, uuid.UUID(invite["id"]))
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
await db_session.commit()
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400
+181 -1
View File
@@ -3,7 +3,7 @@ import uuid
from sqlalchemy import select
from app.models import Room, RoomMembership, RoomRole
from tests.conftest import register_and_login
from tests.conftest import login_as, register_and_login
async def test_create_room_requires_auth(client):
@@ -75,3 +75,183 @@ async def test_join_private_room_400(client, db_session):
resp = await client.post(f"/api/rooms/{private_room.id}/join")
assert resp.status_code == 400
async def test_create_private_room_excluded_from_open_list_but_in_mine(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/rooms", json={"name": "secret", "is_private": True})
assert resp.status_code == 201
assert resp.json()["is_private"] is True
open_names = {r["name"] for r in (await client.get("/api/rooms")).json()}
assert "secret" not in open_names
mine = (await client.get("/api/rooms/mine")).json()
assert mine[0]["name"] == "secret"
assert mine[0]["role"] == "owner"
async def test_update_room_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.patch(f"/api/rooms/{room_id}", json={"description": "nope"})
assert resp.status_code == 403
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.patch(f"/api/rooms/{room_id}", json={"description": "updated"})
assert resp.status_code == 200
assert resp.json()["description"] == "updated"
async def test_delete_room_owner_only(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.delete(f"/api/rooms/{room_id}")
assert resp.status_code == 403
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.delete(f"/api/rooms/{room_id}")
assert resp.status_code == 204
result = await db_session.execute(
select(RoomMembership).where(RoomMembership.room_id == uuid.UUID(room_id))
)
assert result.scalar_one_or_none() is None
async def test_leave_room(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
resp = await client.post(f"/api/rooms/{room_id}/leave")
assert resp.status_code == 400 # owner must transfer first
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.post(f"/api/rooms/{room_id}/leave")
assert resp.status_code == 204
resp = await client.get(f"/api/rooms/{room_id}/messages")
assert resp.status_code == 403 # no longer a member
async def test_remove_member(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.delete(f"/api/rooms/{room_id}/members/{bob['id']}")
assert resp.status_code == 204
resp = await client.delete(f"/api/rooms/{room_id}/members/{bob['id']}")
assert resp.status_code == 404
async def test_admin_cannot_remove_another_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
carol = await register_and_login(client, db_session, username="carol")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
resp = await client.patch(f"/api/rooms/{room_id}/members/{carol['id']}", json={"role": "admin"})
assert resp.status_code == 200
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.delete(f"/api/rooms/{room_id}/members/{carol['id']}")
assert resp.status_code == 403
async def test_cannot_remove_owner(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
resp = await client.delete(f"/api/rooms/{room_id}/members/{(await client.get('/api/auth/me')).json()['id']}")
assert resp.status_code == 400
async def test_transfer_ownership(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room_id}/transfer-ownership", json={"new_owner_user_id": bob["id"]}
)
assert resp.status_code == 200
assert resp.json()["owner_id"] == bob["id"]
resp = await client.post(f"/api/rooms/{room_id}/leave")
assert resp.status_code == 204 # alice is admin now, not owner, so she can leave
result = await db_session.execute(
select(RoomMembership).where(
RoomMembership.room_id == uuid.UUID(room_id), RoomMembership.user_id == uuid.UUID(bob["id"])
)
)
assert result.scalar_one().role == RoomRole.owner
async def test_change_member_role_owner_only(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
assert resp.status_code == 403 # bob is a plain member, not owner
async def test_list_room_members(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
resp = await client.get(f"/api/rooms/{room_id}/members")
assert resp.status_code == 200
members = resp.json()
assert len(members) == 1
assert members[0]["username"] == "alice"
assert members[0]["role"] == "owner"