diff --git a/README.md b/README.md index 5d2646e..fdd3074 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,22 @@ A web-based team chat service (Mattermost-style, no threaded conversations), invite-only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design and phased build plan. -**Phase 1** (this state of the repo): auth, open-room CRUD, and single-instance -WebSocket chat, backend + a minimal frontend. Later phases (private rooms, -push notifications, Redis fan-out, the admin portal, the bot/extension -system, and production deployment) are tracked as issues in the repo's issue -tracker, prioritized. +**Phase 1**: auth, open-room CRUD, and single-instance WebSocket chat, backend ++ a minimal frontend. **Phase 2**: private rooms, room roles (owner/admin/ +member), and room invites — backend only, see below. Later phases (push +notifications, Redis fan-out, the admin portal, the bot/extension system, and +production deployment) are tracked as issues in the repo's issue tracker, +prioritized. ## Structure - [`backend/`](backend/) — FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. See - [`backend/README.md`](backend/README.md) for local setup, migrations, and - how to create a user (registration is invite-only — there's no public - sign-up endpoint). + [`backend/README.md`](backend/README.md) for local setup, migrations, how to + create a user (site registration is invite-only — no public sign-up + endpoint), and the Phase 2 room-roles/invites API. - [`frontend/`](frontend/) — React + Vite PWA (login, room list, chat view). + Still Phase-1-only: it doesn't yet call any of the Phase 2 endpoints. A UI + redesign is happening separately; frontend work resumes once that lands. ## Quickstart diff --git a/backend/README.md b/backend/README.md index de613af..c21d379 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,7 +1,8 @@ -# KeepItTalking backend (Phase 1) +# KeepItTalking backend (Phase 1 + 2) -FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, open-room CRUD, -and a single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the +FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, room CRUD +(open and private), room roles (owner/admin/member) and invites, and a +single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the full system design and the phased build plan. This is an **invite-only site**: there is no public registration endpoint. @@ -79,29 +80,48 @@ app/ main.py create_app(), session middleware, router/WS mounting config.py environment-driven settings (pydantic-settings) database.py async engine/session, get_db() dependency - dependencies.py get_current_user, require_room_member + dependencies.py get_current_user, require_room_member, require_room_role security.py argon2 password hashing cli.py `python -m app.cli create-user` (account provisioning) - models/ SQLAlchemy models (users, rooms, room_memberships, messages) + models/ SQLAlchemy models (users, rooms, room_memberships, + messages, room_invites) schemas/ Pydantic request/response models - routers/ auth, rooms, health + routers/ auth, rooms, invites, health services/ business logic called by routers ws/ WebSocket connection manager + /ws/chat handler alembic/ migrations tests/ pytest + httpx/TestClient tests ``` +## Room roles and invites (Phase 2) + +Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or +`private` (`is_private: true` at creation — joinable only via invite). Room +roles are `owner` > `admin` > `member`: +- **member**: post messages, leave the room +- **admin**: edit room settings, create/list/revoke invites, remove plain members +- **owner**: everything admin can, plus delete the room, remove admins, change + member roles, and transfer ownership + +Invite flow: an admin+ calls `POST /api/rooms/{id}/invites` with an existing +`target_username`; the invited user sees it via `GET /api/invites/mine` and +calls `POST /api/invites/{id}/accept` (or `/decline`). `GET /api/rooms/mine` +lists every room (open + private) the current user belongs to, alongside +their role. + ## Notes / scope decisions -- Invite-only: no `POST /api/auth/register`. Accounts are provisioned with - `python -m app.cli create-user` (see step 4 above). A more self-service - invite flow (per-user tokens, or an admin-portal "generate invite" button) - is a natural phase-2/6 follow-up, not built now. +- Invite-only site registration: no `POST /api/auth/register`. Accounts are + provisioned with `python -m app.cli create-user` (see step 4 above). This is + separate from *room* invites above — site accounts vs. room membership. +- Room invites are by **username only** — `room_invites.target_email` exists + in the schema (per `ARCHITECTURE.md`) but is unused, since there's no + email-delivery mechanism anywhere in the stack yet. - Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth through a WebSocket handshake). This means there's currently no way to force- revoke a session server-side; that needs a real session table later. - No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation. -- `rooms.is_private` exists in the schema but the API never sets it `True` yet; - private rooms/invites are phase 2 (tracked as a Gitea issue). +- Deleting a room explicitly deletes its messages/memberships/invites first + (`room_service.delete_room`) rather than relying on DB-level cascades. diff --git a/backend/alembic/versions/0699d20789b3_room_invites.py b/backend/alembic/versions/0699d20789b3_room_invites.py new file mode 100644 index 0000000..cc3a1e7 --- /dev/null +++ b/backend/alembic/versions/0699d20789b3_room_invites.py @@ -0,0 +1,53 @@ +"""room invites + +Revision ID: 0699d20789b3 +Revises: c7981d17890c +Create Date: 2026-08-13 20:14:22.346544 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0699d20789b3' +down_revision: Union[str, Sequence[str], None] = 'c7981d17890c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('room_invites', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('room_id', sa.Uuid(), nullable=False), + sa.Column('invited_by', sa.Uuid(), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('target_user_id', sa.Uuid(), nullable=True), + sa.Column('target_email', sa.String(length=255), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', sa.Enum('pending', 'accepted', 'revoked', name='invite_status'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('target_user_id IS NOT NULL OR target_email IS NOT NULL', name='room_invites_target_required'), + sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.ForeignKeyConstraint(['target_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_room_invites_room_id'), 'room_invites', ['room_id'], unique=False) + op.create_index(op.f('ix_room_invites_target_user_id'), 'room_invites', ['target_user_id'], unique=False) + op.create_index(op.f('ix_room_invites_token'), 'room_invites', ['token'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_room_invites_token'), table_name='room_invites') + op.drop_index(op.f('ix_room_invites_target_user_id'), table_name='room_invites') + op.drop_index(op.f('ix_room_invites_room_id'), table_name='room_invites') + op.drop_table('room_invites') + # ### end Alembic commands ### diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 33e7876..a722f4c 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -5,7 +5,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.models import RoomMembership, User +from app.models import RoomMembership, RoomRole, User + +_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2} async def get_current_user( @@ -35,3 +37,12 @@ async def require_room_member( if membership is None: raise HTTPException(status_code=403, detail="Not a member of this room") return membership + + +async def require_room_role( + room_id: uuid.UUID, user: User, db: AsyncSession, minimum: RoomRole +) -> RoomMembership: + membership = await require_room_member(room_id, user, db) + if _ROLE_RANK[membership.role] < _ROLE_RANK[minimum]: + raise HTTPException(status_code=403, detail="Insufficient room role") + return membership diff --git a/backend/app/main.py b/backend/app/main.py index c31b923..195b630 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from starlette.middleware.sessions import SessionMiddleware from app.config import settings -from app.routers import auth, health, rooms +from app.routers import auth, health, invites, rooms from app.ws.chat import router as ws_router from app.ws.connection_manager import ConnectionManager @@ -23,6 +23,7 @@ def create_app() -> FastAPI: app.include_router(health.router) app.include_router(auth.router) app.include_router(rooms.router) + app.include_router(invites.router) app.include_router(ws_router) return app diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9077cd4..cd1b937 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,17 @@ from app.models.base import Base +from app.models.invite import InviteStatus, RoomInvite from app.models.membership import RoomMembership, RoomRole from app.models.message import Message from app.models.room import Room from app.models.user import User -__all__ = ["Base", "User", "Room", "RoomMembership", "RoomRole", "Message"] +__all__ = [ + "Base", + "User", + "Room", + "RoomMembership", + "RoomRole", + "Message", + "RoomInvite", + "InviteStatus", +] diff --git a/backend/app/models/invite.py b/backend/app/models/invite.py new file mode 100644 index 0000000..61d4c42 --- /dev/null +++ b/backend/app/models/invite.py @@ -0,0 +1,53 @@ +import enum +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + +DEFAULT_INVITE_LIFETIME = timedelta(days=7) + + +def _default_expires_at() -> datetime: + return datetime.now(timezone.utc) + DEFAULT_INVITE_LIFETIME + + +class InviteStatus(str, enum.Enum): + pending = "pending" + accepted = "accepted" + revoked = "revoked" + + +class RoomInvite(Base): + __tablename__ = "room_invites" + __table_args__ = ( + CheckConstraint( + "target_user_id IS NOT NULL OR target_email IS NOT NULL", + name="room_invites_target_required", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False) + invited_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) + token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + target_user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), index=True) + # Stored per the documented schema but not actionable yet: there's no + # email-delivery mechanism anywhere in the stack. Phase 2 only creates + # invites via target_user_id (existing users, looked up by username). + target_email: Mapped[str | None] = mapped_column(String(255)) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_default_expires_at, nullable=False + ) + status: Mapped[InviteStatus] = mapped_column( + Enum(InviteStatus, name="invite_status"), default=InviteStatus.pending, nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + room = relationship("Room") + inviter = relationship("User", foreign_keys=[invited_by]) + target_user = relationship("User", foreign_keys=[target_user_id]) diff --git a/backend/app/routers/invites.py b/backend/app/routers/invites.py new file mode 100644 index 0000000..22c2606 --- /dev/null +++ b/backend/app/routers/invites.py @@ -0,0 +1,70 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import get_current_user +from app.models import User +from app.schemas.invite import InviteRead +from app.schemas.room import RoomMemberRead +from app.services.invite_service import ( + InviteExpiredError, + InviteNotFoundError, + InviteNotPendingError, + WrongInviteTargetError, + accept_invite, + decline_invite, + list_my_invites, +) + +router = APIRouter(prefix="/api/invites", tags=["invites"]) + + +@router.get("/mine", response_model=list[InviteRead]) +async def list_my_invites_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await list_my_invites(db, current_user.id) + + +@router.post("/{invite_id}/accept", response_model=RoomMemberRead) +async def accept_invite_endpoint( + invite_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + membership = await accept_invite(db, invite_id, current_user.id) + except InviteNotFoundError: + raise HTTPException(status_code=404, detail="Invite not found") + except WrongInviteTargetError: + raise HTTPException(status_code=403, detail="This invite is not addressed to you") + except InviteNotPendingError: + raise HTTPException(status_code=400, detail="Invite is no longer pending") + except InviteExpiredError: + raise HTTPException(status_code=400, detail="Invite has expired") + + return RoomMemberRead( + user_id=membership.user_id, + username=current_user.username, + role=membership.role, + joined_at=membership.joined_at, + ) + + +@router.post("/{invite_id}/decline", response_model=InviteRead) +async def decline_invite_endpoint( + invite_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + return await decline_invite(db, invite_id, current_user.id) + except InviteNotFoundError: + raise HTTPException(status_code=404, detail="Invite not found") + except WrongInviteTargetError: + raise HTTPException(status_code=403, detail="This invite is not addressed to you") + except InviteNotPendingError: + raise HTTPException(status_code=400, detail="Invite is no longer pending") diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 736cd45..4a0ccb4 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -4,19 +4,51 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.dependencies import get_current_user, require_room_member -from app.models import User +from app.dependencies import get_current_user, require_room_member, require_room_role +from app.models import RoomRole, User +from app.schemas.invite import InviteCreate, InviteRead from app.schemas.message import MessageRead -from app.schemas.room import RoomCreate, RoomListItem, RoomRead +from app.schemas.room import ( + MyRoomItem, + RoomCreate, + RoomListItem, + RoomMemberRead, + RoomMemberRoleUpdate, + RoomRead, + RoomUpdate, + TransferOwnershipRequest, +) +from app.services.invite_service import ( + AlreadyMemberError, + DuplicateInviteError, + InviteNotFoundError, + InviteNotPendingError, + TargetUserNotFoundError, + create_invite, + list_room_invites, + revoke_invite, +) from app.services.message_service import list_recent_messages from app.services.room_service import ( + CannotRemoveOwnerError, DuplicateRoomError, + InsufficientRoleError, + MembershipNotFoundError, + OwnerMustTransferError, RoomIsPrivateError, RoomNotFoundError, + change_member_role, create_room, + delete_room, get_room, join_room, + leave_room, + list_member_rooms, list_open_rooms, + list_room_members, + remove_member, + transfer_ownership, + update_room, ) router = APIRouter(prefix="/api/rooms", tags=["rooms"]) @@ -54,6 +86,57 @@ async def list_rooms_endpoint( ] +@router.get("/mine", response_model=list[MyRoomItem]) +async def list_my_rooms_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + rooms = await list_member_rooms(db, current_user.id) + return [ + MyRoomItem( + id=room.id, + name=room.name, + description=room.description, + is_private=room.is_private, + owner_id=room.owner_id, + created_at=room.created_at, + role=role, + ) + for room, role in rooms + ] + + +@router.patch("/{room_id}", response_model=RoomRead) +async def update_room_endpoint( + room_id: uuid.UUID, + data: RoomUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + room = await get_room(db, room_id) + await require_room_role(room_id, current_user, db, RoomRole.admin) + return await update_room(db, room, data) + except RoomNotFoundError: + raise HTTPException(status_code=404, detail="Room not found") + except DuplicateRoomError: + raise HTTPException(status_code=409, detail="A room with this name already exists") + + +@router.delete("/{room_id}", status_code=204) +async def delete_room_endpoint( + room_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + room = await get_room(db, room_id) + await require_room_role(room_id, current_user, db, RoomRole.owner) + await delete_room(db, room) + except RoomNotFoundError: + raise HTTPException(status_code=404, detail="Room not found") + + @router.post("/{room_id}/join", response_model=RoomRead) async def join_room_endpoint( room_id: uuid.UUID, @@ -69,6 +152,103 @@ async def join_room_endpoint( raise HTTPException(status_code=400, detail="Cannot join a private room directly") +@router.post("/{room_id}/leave", status_code=204) +async def leave_room_endpoint( + room_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_member(room_id, current_user, db) + try: + await leave_room(db, room_id, current_user.id) + except OwnerMustTransferError: + raise HTTPException( + status_code=400, detail="Transfer ownership before leaving this room" + ) + except MembershipNotFoundError: + raise HTTPException(status_code=404, detail="Not a member of this room") + + +@router.get("/{room_id}/members", response_model=list[RoomMemberRead]) +async def list_room_members_endpoint( + room_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_member(room_id, current_user, db) + memberships = await list_room_members(db, room_id) + return [ + RoomMemberRead( + user_id=m.user_id, username=m.user.username, role=m.role, joined_at=m.joined_at + ) + for m in memberships + ] + + +@router.delete("/{room_id}/members/{user_id}", status_code=204) +async def remove_member_endpoint( + room_id: uuid.UUID, + user_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + membership = await require_room_role(room_id, current_user, db, RoomRole.admin) + try: + await remove_member(db, room_id, user_id, acting_role=membership.role) + except MembershipNotFoundError: + raise HTTPException(status_code=404, detail="That user is not a member of this room") + except CannotRemoveOwnerError: + raise HTTPException( + status_code=400, detail="Room owner must transfer ownership before being removed" + ) + except InsufficientRoleError: + raise HTTPException(status_code=403, detail="Only the owner can remove an admin") + + +@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead) +async def change_member_role_endpoint( + room_id: uuid.UUID, + user_id: uuid.UUID, + data: RoomMemberRoleUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_role(room_id, current_user, db, RoomRole.owner) + try: + membership = await change_member_role(db, room_id, user_id, data.role) + except MembershipNotFoundError: + raise HTTPException(status_code=404, detail="That user is not a member of this room") + except InsufficientRoleError: + raise HTTPException( + status_code=400, detail="Use transfer-ownership to change the room owner" + ) + return RoomMemberRead( + user_id=membership.user_id, + username=membership.user.username, + role=membership.role, + joined_at=membership.joined_at, + ) + + +@router.post("/{room_id}/transfer-ownership", response_model=RoomRead) +async def transfer_ownership_endpoint( + room_id: uuid.UUID, + data: TransferOwnershipRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + room = await get_room(db, room_id) + await require_room_role(room_id, current_user, db, RoomRole.owner) + return await transfer_ownership(db, room, current_user.id, data.new_owner_user_id) + except RoomNotFoundError: + raise HTTPException(status_code=404, detail="Room not found") + except MembershipNotFoundError: + raise HTTPException( + status_code=404, detail="The new owner must already be a member of this room" + ) + + @router.get("/{room_id}/messages", response_model=list[MessageRead]) async def get_room_messages_endpoint( room_id: uuid.UUID, @@ -78,3 +258,47 @@ async def get_room_messages_endpoint( ): await require_room_member(room_id, current_user, db) return await list_recent_messages(db, room_id, limit) + + +@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201) +async def create_invite_endpoint( + room_id: uuid.UUID, + data: InviteCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_role(room_id, current_user, db, RoomRole.admin) + try: + return await create_invite(db, room_id, current_user.id, data.target_username) + except TargetUserNotFoundError: + raise HTTPException(status_code=404, detail="No user with that username") + except AlreadyMemberError: + raise HTTPException(status_code=409, detail="That user is already a member") + except DuplicateInviteError: + raise HTTPException(status_code=409, detail="That user already has a pending invite") + + +@router.get("/{room_id}/invites", response_model=list[InviteRead]) +async def list_room_invites_endpoint( + room_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_role(room_id, current_user, db, RoomRole.admin) + return await list_room_invites(db, room_id) + + +@router.delete("/{room_id}/invites/{invite_id}", status_code=204) +async def revoke_invite_endpoint( + room_id: uuid.UUID, + invite_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_role(room_id, current_user, db, RoomRole.admin) + try: + await revoke_invite(db, room_id, invite_id) + except InviteNotFoundError: + raise HTTPException(status_code=404, detail="Invite not found") + except InviteNotPendingError: + raise HTTPException(status_code=400, detail="Invite is no longer pending") diff --git a/backend/app/schemas/invite.py b/backend/app/schemas/invite.py new file mode 100644 index 0000000..3127bef --- /dev/null +++ b/backend/app/schemas/invite.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.models import InviteStatus + + +class InviteCreate(BaseModel): + target_username: str = Field(min_length=1) + + +class InviteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + room_id: uuid.UUID + invited_by: uuid.UUID + target_user_id: uuid.UUID | None + status: InviteStatus + expires_at: datetime + created_at: datetime diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py index 8af28aa..afcd403 100644 --- a/backend/app/schemas/room.py +++ b/backend/app/schemas/room.py @@ -3,10 +3,18 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field +from app.models import RoomRole + class RoomCreate(BaseModel): name: str = Field(min_length=1, max_length=100) description: str | None = Field(default=None, max_length=2000) + is_private: bool = False + + +class RoomUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=2000) class RoomRead(BaseModel): @@ -22,3 +30,22 @@ class RoomRead(BaseModel): class RoomListItem(RoomRead): is_member: bool + + +class MyRoomItem(RoomRead): + role: RoomRole + + +class RoomMemberRead(BaseModel): + user_id: uuid.UUID + username: str + role: RoomRole + joined_at: datetime + + +class RoomMemberRoleUpdate(BaseModel): + role: RoomRole + + +class TransferOwnershipRequest(BaseModel): + new_owner_user_id: uuid.UUID diff --git a/backend/app/services/invite_service.py b/backend/app/services/invite_service.py new file mode 100644 index 0000000..7a687ad --- /dev/null +++ b/backend/app/services/invite_service.py @@ -0,0 +1,155 @@ +import secrets +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import InviteStatus, RoomInvite, RoomMembership, RoomRole, User + + +class TargetUserNotFoundError(Exception): + pass + + +class AlreadyMemberError(Exception): + pass + + +class DuplicateInviteError(Exception): + pass + + +class InviteNotFoundError(Exception): + pass + + +class WrongInviteTargetError(Exception): + pass + + +class InviteNotPendingError(Exception): + pass + + +class InviteExpiredError(Exception): + pass + + +async def create_invite( + db: AsyncSession, room_id: uuid.UUID, invited_by: uuid.UUID, target_username: str +) -> RoomInvite: + result = await db.execute(select(User).where(User.username == target_username)) + target = result.scalar_one_or_none() + if target is None: + raise TargetUserNotFoundError() + + existing_membership = await db.execute( + select(RoomMembership).where( + RoomMembership.room_id == room_id, RoomMembership.user_id == target.id + ) + ) + if existing_membership.scalar_one_or_none() is not None: + raise AlreadyMemberError() + + existing_invite = await db.execute( + select(RoomInvite).where( + RoomInvite.room_id == room_id, + RoomInvite.target_user_id == target.id, + RoomInvite.status == InviteStatus.pending, + ) + ) + if existing_invite.scalar_one_or_none() is not None: + raise DuplicateInviteError() + + invite = RoomInvite( + room_id=room_id, + invited_by=invited_by, + token=secrets.token_urlsafe(32), + target_user_id=target.id, + ) + db.add(invite) + await db.commit() + await db.refresh(invite) + return invite + + +async def list_room_invites(db: AsyncSession, room_id: uuid.UUID) -> list[RoomInvite]: + result = await db.execute( + select(RoomInvite).where( + RoomInvite.room_id == room_id, RoomInvite.status == InviteStatus.pending + ) + ) + return list(result.scalars().all()) + + +async def list_my_invites(db: AsyncSession, user_id: uuid.UUID) -> list[RoomInvite]: + result = await db.execute( + select(RoomInvite) + .where( + RoomInvite.target_user_id == user_id, + RoomInvite.status == InviteStatus.pending, + RoomInvite.expires_at > datetime.now(timezone.utc), + ) + .options(selectinload(RoomInvite.room)) + ) + return list(result.scalars().all()) + + +async def _get_invite(db: AsyncSession, invite_id: uuid.UUID) -> RoomInvite: + invite = await db.get(RoomInvite, invite_id) + if invite is None: + raise InviteNotFoundError() + return invite + + +async def accept_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership: + invite = await _get_invite(db, invite_id) + if invite.target_user_id != user_id: + raise WrongInviteTargetError() + if invite.status != InviteStatus.pending: + raise InviteNotPendingError() + if invite.expires_at <= datetime.now(timezone.utc): + raise InviteExpiredError() + + result = await db.execute( + select(RoomMembership).where( + RoomMembership.room_id == invite.room_id, RoomMembership.user_id == user_id + ) + ) + membership = result.scalar_one_or_none() + if membership is None: + membership = RoomMembership(room_id=invite.room_id, user_id=user_id, role=RoomRole.member) + db.add(membership) + + invite.status = InviteStatus.accepted + await db.commit() + await db.refresh(membership) + return membership + + +async def decline_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomInvite: + invite = await _get_invite(db, invite_id) + if invite.target_user_id != user_id: + raise WrongInviteTargetError() + if invite.status != InviteStatus.pending: + raise InviteNotPendingError() + + invite.status = InviteStatus.revoked + await db.commit() + await db.refresh(invite) + return invite + + +async def revoke_invite(db: AsyncSession, room_id: uuid.UUID, invite_id: uuid.UUID) -> RoomInvite: + invite = await _get_invite(db, invite_id) + if invite.room_id != room_id: + raise InviteNotFoundError() + if invite.status != InviteStatus.pending: + raise InviteNotPendingError() + + invite.status = InviteStatus.revoked + await db.commit() + await db.refresh(invite) + return invite diff --git a/backend/app/services/room_service.py b/backend/app/services/room_service.py index 3c478e2..2eb639b 100644 --- a/backend/app/services/room_service.py +++ b/backend/app/services/room_service.py @@ -1,12 +1,12 @@ import uuid -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models import Room, RoomMembership, RoomRole -from app.schemas.room import RoomCreate +from app.models import Message, Room, RoomInvite, RoomMembership, RoomRole +from app.schemas.room import RoomCreate, RoomUpdate class DuplicateRoomError(Exception): @@ -21,8 +21,29 @@ class RoomIsPrivateError(Exception): pass +class MembershipNotFoundError(Exception): + pass + + +class CannotRemoveOwnerError(Exception): + pass + + +class InsufficientRoleError(Exception): + pass + + +class OwnerMustTransferError(Exception): + pass + + async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room: - room = Room(name=data.name, description=data.description, owner_id=owner_id) + room = Room( + name=data.name, + description=data.description, + is_private=data.is_private, + owner_id=owner_id, + ) db.add(room) try: await db.flush() @@ -49,6 +70,16 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro ] +async def list_member_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, RoomRole]]: + result = await db.execute( + select(Room, RoomMembership.role) + .join(RoomMembership, RoomMembership.room_id == Room.id) + .where(RoomMembership.user_id == user_id) + .order_by(Room.created_at) + ) + return [(room, role) for room, role in result.all()] + + async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room: room = await db.get(Room, room_id) if room is None: @@ -75,3 +106,105 @@ async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> await db.commit() await db.refresh(membership) return membership + + +async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room: + if data.name is not None: + room.name = data.name + if data.description is not None: + room.description = data.description + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise DuplicateRoomError() from exc + await db.refresh(room) + return room + + +async def delete_room(db: AsyncSession, room: Room) -> None: + # Explicit deletes rather than relying on ORM cascade + eager-loading — + # simpler and more predictable in async code. + await db.execute(delete(Message).where(Message.room_id == room.id)) + await db.execute(delete(RoomInvite).where(RoomInvite.room_id == room.id)) + await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id)) + await db.delete(room) + await db.commit() + + +async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]: + result = await db.execute( + select(RoomMembership) + .where(RoomMembership.room_id == room_id) + .options(selectinload(RoomMembership.user)) + .order_by(RoomMembership.joined_at) + ) + return list(result.scalars().all()) + + +async def _get_membership( + db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID +) -> RoomMembership: + result = await db.execute( + select(RoomMembership).where( + RoomMembership.room_id == room_id, RoomMembership.user_id == user_id + ) + ) + membership = result.scalar_one_or_none() + if membership is None: + raise MembershipNotFoundError() + return membership + + +async def remove_member( + db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, acting_role: RoomRole +) -> None: + membership = await _get_membership(db, room_id, target_user_id) + if membership.role == RoomRole.owner: + raise CannotRemoveOwnerError() + if membership.role == RoomRole.admin and acting_role != RoomRole.owner: + raise InsufficientRoleError() + + await db.delete(membership) + await db.commit() + + +async def change_member_role( + db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, new_role: RoomRole +) -> RoomMembership: + membership = await _get_membership(db, room_id, target_user_id) + if membership.role == RoomRole.owner or new_role == RoomRole.owner: + # Ownership changes only happen through transfer_ownership. + raise InsufficientRoleError() + + membership.role = new_role + await db.commit() + result = await db.execute( + select(RoomMembership) + .where(RoomMembership.room_id == room_id, RoomMembership.user_id == target_user_id) + .options(selectinload(RoomMembership.user)) + ) + return result.scalar_one() + + +async def transfer_ownership( + db: AsyncSession, room: Room, current_owner_id: uuid.UUID, new_owner_user_id: uuid.UUID +) -> Room: + new_owner_membership = await _get_membership(db, room.id, new_owner_user_id) + current_owner_membership = await _get_membership(db, room.id, current_owner_id) + + new_owner_membership.role = RoomRole.owner + current_owner_membership.role = RoomRole.admin + room.owner_id = new_owner_user_id + await db.commit() + await db.refresh(room) + return room + + +async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None: + membership = await _get_membership(db, room_id, user_id) + if membership.role == RoomRole.owner: + raise OwnerMustTransferError() + + await db.delete(membership) + await db.commit() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0eed1c8..50c7e30 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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}, diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py new file mode 100644 index 0000000..0e67635 --- /dev/null +++ b/backend/tests/test_invites.py @@ -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 diff --git a/backend/tests/test_rooms.py b/backend/tests/test_rooms.py index 7d26635..5013b95 100644 --- a/backend/tests/test_rooms.py +++ b/backend/tests/test_rooms.py @@ -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"