Merge branch 'main' into verify-4daac3f1cfba
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
"""add group tables (groups, group_members)
|
||||||
|
|
||||||
|
Revision ID: 0009_add_group_tables
|
||||||
|
Revises: 0008_add_chat_tables
|
||||||
|
Create Date: 2026-09-15 00:00:00.000000
|
||||||
|
|
||||||
|
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
|
||||||
|
-- create/join/leave, role authorization, and the group page endpoint live
|
||||||
|
in a sibling task ("Groups API") that depends on this one.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0009_add_group_tables"
|
||||||
|
down_revision: Union[str, None] = "0008_add_chat_tables"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
|
||||||
|
group_role.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"groups",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"owner_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"group_members",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"group_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default="member",
|
||||||
|
),
|
||||||
|
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
|
||||||
|
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_group_members_user_id", table_name="group_members")
|
||||||
|
op.drop_index("ix_group_members_group_id", table_name="group_members")
|
||||||
|
op.drop_table("group_members")
|
||||||
|
|
||||||
|
op.drop_index("ix_groups_owner_id", table_name="groups")
|
||||||
|
op.drop_table("groups")
|
||||||
|
|
||||||
|
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -8,10 +8,12 @@ from app.models.social import Friendship, Follow, Block, FriendshipStatus
|
|||||||
from app.models.chat import (
|
from app.models.chat import (
|
||||||
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
|
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
|
||||||
)
|
)
|
||||||
|
from app.models.groups import Group, GroupMember, GroupRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Session", "World", "Asset", "AvatarLoadout",
|
"User", "Session", "World", "Asset", "AvatarLoadout",
|
||||||
"WorldRoom", "WorldChunk", "WorldChunkRevision",
|
"WorldRoom", "WorldChunk", "WorldChunkRevision",
|
||||||
"Friendship", "Follow", "Block", "FriendshipStatus",
|
"Friendship", "Follow", "Block", "FriendshipStatus",
|
||||||
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
||||||
|
"Group", "GroupMember", "GroupRole",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Groups: named groups with owner/admin/member roles.
|
||||||
|
|
||||||
|
docs/planning/social.md §4/§5 (US-5). This module is models + schema only --
|
||||||
|
create/join/leave, role authorization (only the owner may transfer ownership
|
||||||
|
or promote/demote admins), and the group page aggregate endpoint belong to
|
||||||
|
the API router task ("Groups API") that depends on this one.
|
||||||
|
|
||||||
|
Delete-behavior choices:
|
||||||
|
|
||||||
|
- ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable,
|
||||||
|
multi-member community, not content solely attributable to one person like
|
||||||
|
a ``World``/``Asset``. Cascading the group away (or hard-blocking the
|
||||||
|
owner's account deletion) would punish every other member for the owner's
|
||||||
|
choice to leave the platform. Instead the group and its full membership
|
||||||
|
list survive with ``owner_id`` nulled out; the sibling API task is
|
||||||
|
responsible for surfacing an ownerless group (e.g. prompting an admin to
|
||||||
|
claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s
|
||||||
|
actor columns already treat "the actor is gone" as a survivable state
|
||||||
|
elsewhere in this schema.
|
||||||
|
- ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless
|
||||||
|
without the group it belongs to; deleting a group deletes its roster,
|
||||||
|
mirroring ``ConversationParticipant.conversation_id`` in chat.py.
|
||||||
|
- ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own
|
||||||
|
membership rows wherever they appear, matching the join-table convention
|
||||||
|
used by ``ConversationParticipant.user_id`` and the social-graph tables
|
||||||
|
(``Friendship``/``Follow``/``Block``) for plain per-user references.
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import ENUM
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GroupRole(str, enum.Enum):
|
||||||
|
OWNER = "owner"
|
||||||
|
ADMIN = "admin"
|
||||||
|
MEMBER = "member"
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
"""A named group. ``owner_id`` is nullable -- see module docstring for
|
||||||
|
why the group survives its owner's account deletion."""
|
||||||
|
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
owner_id = Column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = relationship("User", back_populates="owned_groups")
|
||||||
|
members = relationship(
|
||||||
|
"GroupMember", back_populates="group", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(Base):
|
||||||
|
"""A group's roster: one row per (group, user) with a role."""
|
||||||
|
|
||||||
|
__tablename__ = "group_members"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
group_id = Column(
|
||||||
|
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
user_id = Column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
role = Column(
|
||||||
|
ENUM(
|
||||||
|
GroupRole,
|
||||||
|
name="group_role",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
default=GroupRole.MEMBER,
|
||||||
|
server_default=GroupRole.MEMBER.value,
|
||||||
|
)
|
||||||
|
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
group = relationship("Group", back_populates="members")
|
||||||
|
user = relationship("User", back_populates="group_memberships")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
|
||||||
|
)
|
||||||
@@ -73,6 +73,17 @@ class User(Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Groups (docs/planning/social.md US-5; app/models/groups.py)
|
||||||
|
owned_groups = relationship(
|
||||||
|
"Group",
|
||||||
|
back_populates="owner",
|
||||||
|
)
|
||||||
|
group_memberships = relationship(
|
||||||
|
"GroupMember",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_adult(self) -> bool:
|
def is_adult(self) -> bool:
|
||||||
"""Return True if the user is 18 or older based on date_of_birth."""
|
"""Return True if the user is 18 or older based on date_of_birth."""
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ def _client_key(request: Request) -> str:
|
|||||||
# brute-forcer.
|
# brute-forcer.
|
||||||
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
|
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
|
||||||
|
|
||||||
|
# Every limiter in the process -- named limits built via `rate_limit()`
|
||||||
|
# below register themselves here too, so `reset_all()` (the test hook) and
|
||||||
|
# any future process-wide accounting stay correct without each caller
|
||||||
|
# having to remember to wire it up.
|
||||||
|
_registered_limiters: list[SlidingWindowLimiter] = [_auth_limiter]
|
||||||
|
|
||||||
|
|
||||||
async def auth_rate_limit(request: Request) -> None:
|
async def auth_rate_limit(request: Request) -> None:
|
||||||
"""FastAPI dependency: rate-limit auth attempts per client IP.
|
"""FastAPI dependency: rate-limit auth attempts per client IP.
|
||||||
@@ -68,6 +74,41 @@ async def auth_rate_limit(request: Request) -> None:
|
|||||||
_auth_limiter.check(f"auth:{_client_key(request)}")
|
_auth_limiter.check(f"auth:{_client_key(request)}")
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit(name: str, *, max_hits: int, window_seconds: float):
|
||||||
|
"""Build a named, per-client-IP FastAPI rate-limit dependency.
|
||||||
|
|
||||||
|
Generalizes the ``auth_rate_limit`` pattern above for other endpoints
|
||||||
|
(e.g. chat send, message report) that want their own independent cap:
|
||||||
|
|
||||||
|
report_rate_limit = rate_limit("message_report", max_hits=5, window_seconds=60)
|
||||||
|
|
||||||
|
@router.post("/report", dependencies=[Depends(report_rate_limit)])
|
||||||
|
...
|
||||||
|
|
||||||
|
Each call gets its own ``SlidingWindowLimiter`` -- counters for one
|
||||||
|
named limit never bleed into another, even for the same client IP --
|
||||||
|
and reuses ``SlidingWindowLimiter.check`` for the actual sliding-window
|
||||||
|
accounting and the 429 + ``Retry-After`` response. Like
|
||||||
|
``auth_rate_limit``, the returned dependency is a no-op when
|
||||||
|
``settings.RATE_LIMIT_ENABLED`` is false, so the checkpoint smoke suite
|
||||||
|
(one client IP for the whole run) doesn't trip on it.
|
||||||
|
|
||||||
|
``name`` only needs to be unique within the process; it namespaces the
|
||||||
|
per-IP key so it doesn't collide with ``auth_rate_limit`` or any other
|
||||||
|
named limit.
|
||||||
|
"""
|
||||||
|
limiter = SlidingWindowLimiter(max_hits=max_hits, window_seconds=window_seconds)
|
||||||
|
_registered_limiters.append(limiter)
|
||||||
|
|
||||||
|
async def _dependency(request: Request) -> None:
|
||||||
|
if not settings.RATE_LIMIT_ENABLED:
|
||||||
|
return
|
||||||
|
limiter.check(f"{name}:{_client_key(request)}")
|
||||||
|
|
||||||
|
return _dependency
|
||||||
|
|
||||||
|
|
||||||
def reset_all() -> None:
|
def reset_all() -> None:
|
||||||
"""Test hook."""
|
"""Test hook."""
|
||||||
_auth_limiter.reset()
|
for limiter in _registered_limiters:
|
||||||
|
limiter.reset()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ out dozens of times.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -50,13 +51,31 @@ TEST_TABLES = (
|
|||||||
# reached by cascading a truncate of messages/conversations; list it
|
# reached by cascading a truncate of messages/conversations; list it
|
||||||
# explicitly along with the rest for clarity/consistency.
|
# explicitly along with the rest for clarity/consistency.
|
||||||
"message_reports", "messages", "conversation_participants", "conversations",
|
"message_reports", "messages", "conversation_participants", "conversations",
|
||||||
|
# Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL
|
||||||
|
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
|
||||||
|
# by cascading a truncate of users; list it explicitly for clarity too.
|
||||||
|
"group_members", "groups",
|
||||||
"sessions", "refresh_tokens", "users",
|
"sessions", "refresh_tokens", "users",
|
||||||
)
|
)
|
||||||
|
|
||||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
_COMPOSE_PROJECT = "agp-pytest"
|
|
||||||
_EPHEMERAL_PG_PORT = 25432
|
# A hardcoded project name/port here would mean every concurrent checkout
|
||||||
_EPHEMERAL_REDIS_PORT = 26379
|
# running `pytest` (e.g. several Claude Agent-tool subagents, each in their
|
||||||
|
# own ~/agp/workspace/task_<id> clone) fights over the exact same Docker
|
||||||
|
# Compose project and host ports -- one run's pytest_unconfigure teardown
|
||||||
|
# can yank the ephemeral Postgres out from under a completely unrelated
|
||||||
|
# concurrent run, surfacing as "connection was closed in the middle of
|
||||||
|
# operation" with no obvious cause. Confirmed live 2026-09-15 running 3
|
||||||
|
# parallel subagents. Same root cause and fix pattern as the agent_core
|
||||||
|
# workspace/Docker-Compose collision documented in project_agp memory --
|
||||||
|
# derive both the project name and the port from a hash of the checkout
|
||||||
|
# directory, so different concurrent checkouts land on different projects/
|
||||||
|
# ports while the same checkout is stable across repeated runs.
|
||||||
|
_WORKSPACE_HASH = hashlib.sha256(str(BACKEND_DIR.parent).encode()).hexdigest()[:10]
|
||||||
|
_COMPOSE_PROJECT = f"agp-pytest-{_WORKSPACE_HASH}"
|
||||||
|
_EPHEMERAL_PG_PORT = 25400 + (int(_WORKSPACE_HASH, 16) % 100)
|
||||||
|
_EPHEMERAL_REDIS_PORT = 26300 + (int(_WORKSPACE_HASH, 16) % 100)
|
||||||
|
|
||||||
# Set by pytest_configure() when it starts container(s) it owns; torn down in
|
# Set by pytest_configure() when it starts container(s) it owns; torn down in
|
||||||
# pytest_unconfigure.
|
# pytest_unconfigure.
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Model-level tests for groups: groups and group_members.
|
||||||
|
|
||||||
|
app/models/groups.py / alembic/versions/0009_add_group_tables.py
|
||||||
|
(docs/planning/social.md US-5). This is the models+migration task only --
|
||||||
|
there are no API endpoints yet (a sibling task, "Groups API", owns
|
||||||
|
create/join/leave/role authorization and the group page endpoint), so these
|
||||||
|
tests exercise the ORM models and the real Postgres constraints directly
|
||||||
|
rather than going through HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.models.groups import Group, GroupMember, GroupRole
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _register(client: TestClient, username: str) -> int:
|
||||||
|
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
|
||||||
|
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
me = client.get("/api/users/me", headers=headers)
|
||||||
|
assert me.status_code == 200, me.text
|
||||||
|
return me.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _session_factory():
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
return engine, async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroupCreationAndRoles:
|
||||||
|
async def test_create_group_with_members_of_different_roles(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner = _register(client, "grp_role_owner")
|
||||||
|
admin = _register(client, "grp_role_admin")
|
||||||
|
member = _register(client, "grp_role_member")
|
||||||
|
|
||||||
|
engine, factory = _session_factory()
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
group = Group(name="Explorers", description="We explore worlds", owner_id=owner)
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
|
||||||
|
GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN),
|
||||||
|
GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
fetched = await session.get(Group, group_id)
|
||||||
|
members = (
|
||||||
|
await session.execute(
|
||||||
|
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
by_user = {m.user_id: m.role for m in members}
|
||||||
|
assert fetched.owner_id == owner
|
||||||
|
assert len(members) == 3
|
||||||
|
assert by_user[owner] == GroupRole.OWNER
|
||||||
|
assert by_user[admin] == GroupRole.ADMIN
|
||||||
|
assert by_user[member] == GroupRole.MEMBER
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
async def test_membership_role_defaults_to_member(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner = _register(client, "grp_default_owner")
|
||||||
|
joiner = _register(client, "grp_default_joiner")
|
||||||
|
|
||||||
|
engine, factory = _session_factory()
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
group = Group(name="Defaults", owner_id=owner)
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
session.add(GroupMember(group_id=group.id, user_id=joiner))
|
||||||
|
await session.commit()
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
gm = (
|
||||||
|
await session.execute(
|
||||||
|
select(GroupMember).where(
|
||||||
|
GroupMember.group_id == group_id, GroupMember.user_id == joiner
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert gm.role == GroupRole.MEMBER
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUniqueMembershipConstraint:
|
||||||
|
async def test_duplicate_membership_rejected(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner = _register(client, "grp_uq_owner")
|
||||||
|
member = _register(client, "grp_uq_member")
|
||||||
|
|
||||||
|
engine, factory = _session_factory()
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
group = Group(name="Uniqueness", owner_id=owner)
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
session.add(GroupMember(group_id=group.id, user_id=member))
|
||||||
|
await session.commit()
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
session.add(GroupMember(group_id=group_id, user_id=member))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCascadeDelete:
|
||||||
|
async def test_deleting_group_deletes_members(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner = _register(client, "grp_cd_group_owner")
|
||||||
|
member = _register(client, "grp_cd_group_member")
|
||||||
|
|
||||||
|
engine, factory = _session_factory()
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
group = Group(name="To Delete", owner_id=owner)
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
|
||||||
|
GroupMember(group_id=group.id, user_id=member),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
# Core delete against groups directly, so the real ON DELETE
|
||||||
|
# CASCADE does the work (not the ORM).
|
||||||
|
await session.execute(delete(Group).where(Group.id == group_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
members = (
|
||||||
|
await session.execute(
|
||||||
|
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert members == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
async def test_deleting_member_user_removes_only_their_membership(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner = _register(client, "grp_cd_user_owner")
|
||||||
|
member = _register(client, "grp_cd_user_member")
|
||||||
|
|
||||||
|
engine, factory = _session_factory()
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
group = Group(name="Membership Cascade", owner_id=owner)
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
|
||||||
|
GroupMember(group_id=group.id, user_id=member),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
await session.execute(delete(User).where(User.id == member))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
remaining = (
|
||||||
|
await session.execute(
|
||||||
|
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
# Only the deleted member's row is gone; the group and the
|
||||||
|
# owner's membership survive.
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].user_id == owner
|
||||||
|
|
||||||
|
group = await session.get(Group, group_id)
|
||||||
|
assert group is not None
|
||||||
|
assert group.owner_id == owner
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
async def test_deleting_owner_survives_with_owner_id_null(self):
|
||||||
|
"""Deleting the owner's account must not delete the group or its
|
||||||
|
other members -- only ``owner_id`` is nulled out (SET NULL), per the
|
||||||
|
module docstring in app/models/groups.py."""
|
||||||
|
client = TestClient(app)
|
||||||
|
owner = _register(client, "grp_cd_owner_owner")
|
||||||
|
member = _register(client, "grp_cd_owner_member")
|
||||||
|
|
||||||
|
engine, factory = _session_factory()
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
group = Group(name="Survives Owner Deletion", owner_id=owner)
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
|
||||||
|
GroupMember(group_id=group.id, user_id=member),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
await session.execute(delete(User).where(User.id == owner))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
group = await session.get(Group, group_id)
|
||||||
|
assert group is not None
|
||||||
|
assert group.owner_id is None
|
||||||
|
assert group.name == "Survives Owner Deletion"
|
||||||
|
|
||||||
|
# The owner's own membership row is gone (user_id CASCADE),
|
||||||
|
# but the other member's row survives untouched.
|
||||||
|
members = (
|
||||||
|
await session.execute(
|
||||||
|
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(members) == 1
|
||||||
|
assert members[0].user_id == member
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""The auth endpoints are rate-limited per client IP."""
|
"""The auth endpoints are rate-limited per client IP."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app import rate_limit
|
from app import rate_limit
|
||||||
@@ -54,3 +55,70 @@ def test_limiter_window_is_per_key():
|
|||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
limiter.check("a")
|
limiter.check("a")
|
||||||
limiter.check("b") # different key, unaffected
|
limiter.check("b") # different key, unaffected
|
||||||
|
|
||||||
|
|
||||||
|
def _make_named_limit_app():
|
||||||
|
"""A tiny standalone app exercising two independent named limits, so
|
||||||
|
these tests don't depend on any sibling endpoint existing yet."""
|
||||||
|
limit_a = rate_limit.rate_limit("test_named_a", max_hits=2, window_seconds=60)
|
||||||
|
limit_b = rate_limit.rate_limit("test_named_b", max_hits=2, window_seconds=60)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.post("/a", dependencies=[Depends(limit_a)])
|
||||||
|
def _a():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/b", dependencies=[Depends(limit_b)])
|
||||||
|
def _b():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_limits_do_not_share_counters():
|
||||||
|
client = _make_named_limit_app()
|
||||||
|
|
||||||
|
# Burn out limit "a" entirely.
|
||||||
|
assert client.post("/a").status_code == 200
|
||||||
|
assert client.post("/a").status_code == 200
|
||||||
|
r = client.post("/a")
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert "Retry-After" in r.headers
|
||||||
|
|
||||||
|
# Limit "b" is untouched -- same process, same client IP, different name.
|
||||||
|
assert client.post("/b").status_code == 200
|
||||||
|
assert client.post("/b").status_code == 200
|
||||||
|
assert client.post("/b").status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_limit_returns_429_with_retry_after_once_exhausted():
|
||||||
|
client = _make_named_limit_app()
|
||||||
|
client.post("/a")
|
||||||
|
client.post("/a")
|
||||||
|
r = client.post("/a")
|
||||||
|
assert r.status_code == 429
|
||||||
|
retry_after = r.headers["Retry-After"]
|
||||||
|
assert int(retry_after) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_limit_is_a_noop_when_rate_limiting_disabled():
|
||||||
|
prev = settings.RATE_LIMIT_ENABLED
|
||||||
|
settings.RATE_LIMIT_ENABLED = False
|
||||||
|
try:
|
||||||
|
client = _make_named_limit_app()
|
||||||
|
codes = [client.post("/a").status_code for _ in range(10)]
|
||||||
|
assert codes == [200] * 10
|
||||||
|
finally:
|
||||||
|
settings.RATE_LIMIT_ENABLED = prev
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_all_clears_named_limiters_too():
|
||||||
|
client = _make_named_limit_app()
|
||||||
|
client.post("/a")
|
||||||
|
client.post("/a")
|
||||||
|
assert client.post("/a").status_code == 429
|
||||||
|
|
||||||
|
rate_limit.reset_all()
|
||||||
|
|
||||||
|
assert client.post("/a").status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user