diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..0ad9e76 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,103 @@ +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.database.session import get_db +from app.schemas.auth import RegisterRequest, UserRead +from app.services.auth import ( + InvalidCredentialsError, + UserAlreadyExistsError, + create_session, + get_user_by_session_token, + login_user, + logout_session, + register_user, +) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def register_handler( + user_cred: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db) +): + try: + user = await register_user(db, user_cred.email, user_cred.password) + except UserAlreadyExistsError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User already exists" + ) + + session_token = await create_session(db, user) + + response.set_cookie( + value=session_token, + key="ackvia_session", + httponly=True, + secure=True, + samesite="lax", + max_age=30 * 24 * 3600, + path="/", + ) + return user + + +@router.post("/login", response_model=UserRead, status_code=status.HTTP_200_OK) +async def login_handler( + user_cred: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db) +): + try: + user, session_token = await login_user(db, user_cred.email, user_cred.password) + except InvalidCredentialsError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Username or password incorrect", + ) + + response.set_cookie( + value=session_token, + key="ackvia_session", + httponly=True, + secure=True, + samesite="lax", + max_age=30 * 24 * 3600, + path="/", + ) + return user + + +async def get_current_user( + ackvia_session: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +) -> User: + if ackvia_session is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User is not authenticated" + ) + user = await get_user_by_session_token(db, ackvia_session) + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User is not authenticated" + ) + + return user + + +@router.get("/me", response_model=UserRead, status_code=status.HTTP_200_OK) +async def get_current_user_handler(user: User = Depends(get_current_user)): + return user + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout_handler( + res: Response, + ackvia_session: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +): + if ackvia_session is not None: + await logout_session(db, ackvia_session) + + res.delete_cookie( + key="ackvia_session", httponly=True, secure=True, samesite="lax", path="/" + ) diff --git a/app/api/deliveries.py b/app/api/deliveries.py index 882787b..3d8aa67 100644 --- a/app/api/deliveries.py +++ b/app/api/deliveries.py @@ -1,13 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.queries.deliveries import get_detailed_delivery_by_id +from app.api.auth import get_current_user +from app.database.models import User +from app.database.queries.deliveries import get_accessible_delivery_by_id from app.database.session import get_db from app.schemas.deliveries import DeliveryDetailRead from app.services.delivery import ( DeliveryNotFoundError, DeliveryNotReplayableError, - manual_delivery, + queue_manual_replay, ) router = APIRouter(prefix="/api/deliveries", tags=["deliveries"]) @@ -17,12 +19,13 @@ async def get_detailed_delivery( delivery_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), ): - res = await get_detailed_delivery_by_id(db, delivery_id) + res = await get_accessible_delivery_by_id(db, delivery_id, user_id=user.id) if res is None: raise HTTPException( - status_code=404, + status_code=status.HTTP_404_NOT_FOUND, detail="Delivery not found", ) @@ -30,13 +33,23 @@ async def get_detailed_delivery( @router.post( - "/{delivery_id}/replay", response_model=DeliveryDetailRead, status_code=202 + "/{delivery_id}/replay", + response_model=DeliveryDetailRead, + status_code=status.HTTP_202_ACCEPTED, ) -async def replay_delivery(delivery_id: int, db: AsyncSession = Depends(get_db)): +async def replay_delivery( + delivery_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): try: - delivery = await manual_delivery(delivery_id, db) + delivery = await queue_manual_replay(db, delivery_id, user.id) except DeliveryNotFoundError: - raise HTTPException(status_code=404, detail="Delivery not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Delivery not found" + ) except DeliveryNotReplayableError: - raise HTTPException(status_code=409, detail="Delivery is not replayable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Delivery is not replayable" + ) return delivery diff --git a/app/api/forms.py b/app/api/forms.py index 866366d..946473c 100644 --- a/app/api/forms.py +++ b/app/api/forms.py @@ -4,15 +4,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession +from app.api.auth import get_current_user from app.core.i18n import SUPPORTED_LANGUAGES, get_locale +from app.database.models import User from app.database.queries.forms import ( + get_accessible_form_by_id, get_all_forms, - get_form_by_id, get_form_submissions_from_db, ) from app.database.session import get_db +from app.exceptions import WorkspaceNotFoundError from app.schemas.forms import FormRead -from app.schemas.submissions import SubmissionBase +from app.schemas.submissions import SubmissionRead from app.services.forms import FormCreate, create_form router = APIRouter(prefix="/api/forms", tags=["forms"]) @@ -23,24 +26,36 @@ async def create_form_endpoint( data: FormCreate, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)], db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), ): current_lang, _ = locale lang = data.language if data.language in SUPPORTED_LANGUAGES else current_lang - return await create_form(db, data.title, lang, data.destinations) + try: + form = await create_form( + db, data.title, lang, data.destinations, user.id, data.workspace_id + ) + return form + except WorkspaceNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found" + ) -@router.get("/{form_id}/submissions", response_model=list[SubmissionBase]) +@router.get("/{form_id}/submissions", response_model=list[SubmissionRead]) async def get_form_submissions( form_id: uuid.UUID, limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), ): - form = await get_form_by_id(db, form_id) + form = await get_accessible_form_by_id(db, form_id, user.id) if form is None: - raise HTTPException(status_code=404, detail="Form not foud") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Form not foud" + ) return await get_form_submissions_from_db( db=db, form_id=form_id, limit=limit, offset=offset @@ -52,5 +67,6 @@ async def get_forms( limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), ): - return await get_all_forms(db=db, limit=limit, offset=offset) + return await get_all_forms(db=db, limit=limit, offset=offset, user_id=user.id) diff --git a/app/api/submissions.py b/app/api/submissions.py index 8a63101..7bf3574 100644 --- a/app/api/submissions.py +++ b/app/api/submissions.py @@ -1,7 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.queries.submissions import get_detailed_submission_by_id +from app.api.auth import get_current_user +from app.database.models import User +from app.database.queries.submissions import get_accessible_submission_by_id from app.database.session import get_db from app.schemas.submissions import SubmissionRead @@ -12,14 +14,15 @@ async def get_detailed_submission( sub_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), ): - detailed_submission = await get_detailed_submission_by_id( - db=db, submission_id=sub_id + detailed_submission = await get_accessible_submission_by_id( + db=db, submission_id=sub_id, user_id=user.id ) if detailed_submission is None: raise HTTPException( - status_code=404, + status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found", ) diff --git a/app/api/workspaces.py b/app/api/workspaces.py new file mode 100644 index 0000000..a63fc87 --- /dev/null +++ b/app/api/workspaces.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth import get_current_user +from app.database.models import User +from app.database.session import get_db +from app.schemas.workspaces import WorkspaceRead +from app.services.workspace import list_user_workspaces + +router = APIRouter(prefix="/api/workspaces", tags=["workspaces"]) + + +@router.get("", response_model=list[WorkspaceRead], status_code=status.HTTP_200_OK) +async def get_user_workspaces_handler( + db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user) +): + return await list_user_workspaces(db, user) diff --git a/app/database/queries/auth.py b/app/database/queries/auth.py new file mode 100644 index 0000000..2f424cb --- /dev/null +++ b/app/database/queries/auth.py @@ -0,0 +1,29 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import Session, User + + +async def get_session_by_token_hash( + db: AsyncSession, token_hash: str +) -> Session | None: + session = await db.execute( + select(Session) + .options(selectinload(Session.user)) + .where(Session.token_hash == token_hash) + ) + return session.scalar_one_or_none() + + +async def get_user_by_email(db: AsyncSession, email: str) -> User | None: + return await db.scalar(select(User).where(User.email == email)) + + +async def delete_session_token_by_token_hash(db: AsyncSession, token_hash: str) -> bool: + session = await db.scalar(select(Session).where(Session.token_hash == token_hash)) + if session is None: + return False + await db.delete(session) + await db.flush() + return True diff --git a/app/database/queries/deliveries.py b/app/database/queries/deliveries.py index dacf0a1..cc1fb15 100644 --- a/app/database/queries/deliveries.py +++ b/app/database/queries/deliveries.py @@ -1,13 +1,36 @@ +import uuid + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import Delivery +from app.database.models import Delivery, Form, Submission, WorkspaceMembership -async def get_detailed_delivery_by_id(db: AsyncSession, delivery_id: int): +async def get_accessible_delivery_by_id( + db: AsyncSession, delivery_id: int, user_id: uuid.UUID +): return await db.scalar( select(Delivery) - .where(Delivery.id == delivery_id) + .join(Submission, Delivery.submission_id == Submission.id) + .join(Form, Form.id == Submission.form_id) + .join( + WorkspaceMembership, WorkspaceMembership.workspace_id == Form.workspace_id + ) .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery_id, WorkspaceMembership.user_id == user_id) + ) + + +async def get_delivery_for_processing( + db: AsyncSession, delivery_id: int +) -> Delivery | None: + result = await db.execute( + select(Delivery) + .where(Delivery.id == delivery_id) + .options( + selectinload(Delivery.destination), + selectinload(Delivery.submission).selectinload(Submission.form), + ) ) + return result.scalar_one_or_none() diff --git a/app/database/queries/forms.py b/app/database/queries/forms.py index 18d98fb..f49cbfc 100644 --- a/app/database/queries/forms.py +++ b/app/database/queries/forms.py @@ -4,11 +4,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import Form, Submission - - -async def get_form_by_id(db: AsyncSession, form_id: uuid.UUID): - return await db.get(Form, form_id) +from app.database.models import Form, Submission, WorkspaceMembership + + +async def get_accessible_form_by_id( + db: AsyncSession, form_id: uuid.UUID, user_id: uuid.UUID +) -> Form | None: + return await db.scalar( + select(Form) + .join( + WorkspaceMembership, Form.workspace_id == WorkspaceMembership.workspace_id + ) + .where(WorkspaceMembership.user_id == user_id, Form.id == form_id) + ) async def get_form_submissions_from_db( @@ -16,6 +24,7 @@ async def get_form_submissions_from_db( ): res = await db.scalars( select(Submission) + .options(selectinload(Submission.deliveries)) .where(Submission.form_id == form_id) .order_by(Submission.created_at.desc()) .offset(offset) @@ -24,9 +33,16 @@ async def get_form_submissions_from_db( return res.all() -async def get_all_forms(db: AsyncSession, limit: int, offset: int): +async def get_all_forms(db: AsyncSession, limit: int, offset: int, user_id: uuid.UUID): res = await db.scalars( - select(Form).order_by(Form.created_at.desc()).offset(offset).limit(limit) + select(Form) + .join( + WorkspaceMembership, WorkspaceMembership.workspace_id == Form.workspace_id + ) + .where(WorkspaceMembership.user_id == user_id) + .order_by(Form.created_at.desc()) + .offset(offset) + .limit(limit) ) return res.all() diff --git a/app/database/queries/submissions.py b/app/database/queries/submissions.py index 7e466ab..387a8dd 100644 --- a/app/database/queries/submissions.py +++ b/app/database/queries/submissions.py @@ -1,3 +1,5 @@ +import uuid + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,14 +10,21 @@ DeliveryTrigger, Form, Submission, + WorkspaceMembership, ) -async def get_detailed_submission_by_id(db: AsyncSession, submission_id: int): +async def get_accessible_submission_by_id( + db: AsyncSession, submission_id: int, user_id: uuid.UUID +) -> Submission | None: return await db.scalar( select(Submission) + .join(Form, Submission.form_id == Form.id) + .join( + WorkspaceMembership, Form.workspace_id == WorkspaceMembership.workspace_id + ) .options(selectinload(Submission.deliveries)) - .where(Submission.id == submission_id) + .where(Submission.id == submission_id, WorkspaceMembership.user_id == user_id) ) diff --git a/app/database/queries/workspace.py b/app/database/queries/workspace.py new file mode 100644 index 0000000..f02325c --- /dev/null +++ b/app/database/queries/workspace.py @@ -0,0 +1,68 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import Workspace, WorkspaceMembership, WorkspaceRole +from app.exceptions import WorkspaceNotFoundError, WorkspacePermissionDeniedError + + +async def get_workspace_membership_by_user_and_workspace_id( + db: AsyncSession, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> WorkspaceMembership | None: + workspace_membership = await db.scalar( + select(WorkspaceMembership) + .options(selectinload(WorkspaceMembership.workspace)) + .where( + WorkspaceMembership.workspace_id == workspace_id + and WorkspaceMembership.user_id == user_id + ) + ) + + return workspace_membership + + +async def get_user_workspaces(db: AsyncSession, user_id: uuid.UUID) -> list[Workspace]: + workspace_memberships = await db.execute( + select(Workspace) + .join(WorkspaceMembership, Workspace.id == WorkspaceMembership.workspace_id) + .where(WorkspaceMembership.user_id == user_id) + ) + return [wm for wm in workspace_memberships.scalars()] + + +async def get_accessible_workspace_by_id( + db: AsyncSession, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> Workspace | None: + return await db.scalar( + select(Workspace) + .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) + .where(Workspace.id == workspace_id, WorkspaceMembership.user_id == user_id) + ) + + +async def get_workspace_membership( + db: AsyncSession, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> WorkspaceMembership | None: + return await db.scalar( + select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace_id, + WorkspaceMembership.user_id == user_id, + ) + ) + + +async def require_workspace_role( + db: AsyncSession, + user_id: uuid.UUID, + workspace_id: uuid.UUID, + allowed_roles: set[WorkspaceRole], +) -> WorkspaceMembership: + workspace_membership = await get_workspace_membership(db, user_id, workspace_id) + + if workspace_membership is None: + raise WorkspaceNotFoundError + if workspace_membership.role not in allowed_roles: + raise WorkspacePermissionDeniedError + return workspace_membership diff --git a/app/database/session.py b/app/database/session.py index 2fa9665..bdeb56c 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -12,4 +12,9 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async with async_session_maker() as session: - yield session + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..8f49e7f --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,24 @@ +class WorkspaceAccessDeniedError(Exception): + pass + + +class InvalidCredentialsError(Exception): + pass + + +class WorkspacePermissionDeniedError(Exception): + pass + + +class UserAlreadyExistsError(Exception): + pass + + +class WorkspaceNotFoundError(Exception): + pass + + +class DeliveryNotFoundError(Exception): ... + + +class DeliveryNotReplayableError(Exception): ... diff --git a/app/main.py b/app/main.py index 91870c8..76f2bad 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,12 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from app.api.auth import router as auth_router from app.api.deliveries import router as deliveries_router from app.api.forms import router as forms_router from app.api.ingest import router as ingest_router from app.api.submissions import router as submissions_router +from app.api.workspaces import router as workspaces_router from app.core.i18n import ( SUPPORTED_LANGUAGES, get_locale, @@ -32,6 +34,8 @@ async def lifespan(app: FastAPI): app.include_router(ingest_router) app.include_router(submissions_router) app.include_router(deliveries_router) +app.include_router(auth_router) +app.include_router(workspaces_router) @app.get("/", response_class=HTMLResponse) diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..85a0f83 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,20 @@ +import uuid + +from pydantic import BaseModel, ConfigDict + + +class RegisterRequest(BaseModel): + email: str + password: str + + +class LoginRequest(RegisterRequest): + pass + + +class UserRead(BaseModel): + id: uuid.UUID + email: str + email_verified: bool + + model_config = ConfigDict(from_attributes=True) diff --git a/app/schemas/workspaces.py b/app/schemas/workspaces.py new file mode 100644 index 0000000..0a19ae6 --- /dev/null +++ b/app/schemas/workspaces.py @@ -0,0 +1,8 @@ +import uuid + +from pydantic import BaseModel + + +class WorkspaceRead(BaseModel): + id: uuid.UUID + name: str diff --git a/app/services/auth.py b/app/services/auth.py index e69de29..0574e37 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -0,0 +1,103 @@ +import hashlib +import secrets +from datetime import UTC, datetime, timedelta + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Session, User +from app.database.queries.auth import ( + delete_session_token_by_token_hash, + get_session_by_token_hash, + get_user_by_email, +) +from app.exceptions import InvalidCredentialsError, UserAlreadyExistsError + +password_hasher = PasswordHasher() +SESSION_TTL = 30 + + +def hash_password(password: str) -> str: + return password_hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + password_hasher.verify(password_hash, password) + except VerifyMismatchError: + return False + except InvalidHashError: + return False + return True + + +def generate_session_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_session_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +async def create_session(db: AsyncSession, user: User) -> str: + token = generate_session_token() + hashed_token = hash_session_token(token) + + session = Session( + token_hash=hashed_token, + expires_at=datetime.now(UTC) + timedelta(days=SESSION_TTL), + user=user, + ) + + db.add(session) + await db.flush() + + return token + + +def normalize_email(email: str) -> str: + return email.strip().lower() + + +async def register_user(db: AsyncSession, email: str, password: str) -> User: + normalized_email = normalize_email(email) + if await get_user_by_email(db, normalized_email) is not None: + raise UserAlreadyExistsError + + user = User(email=normalized_email, password_hash=hash_password(password)) + db.add(user) + await db.flush() + return user + + +async def get_user_by_session_token(db: AsyncSession, token: str) -> User | None: + user_session = await get_session_by_token_hash(db, hash_session_token(token)) + if not user_session: + return None + + if user_session.expires_at <= datetime.now(UTC): + return None + + return user_session.user + + +async def login_user(db, email, password) -> tuple[User, str]: + normalized_email = normalize_email(email) + user = await get_user_by_email(db, normalized_email) + + if user is None: + raise InvalidCredentialsError + if user.password_hash is None: + raise InvalidCredentialsError + if not verify_password(password, user.password_hash): + raise InvalidCredentialsError + + token = await create_session(db, user) + + return user, token + + +async def logout_session(db: AsyncSession, token: str) -> None: + await delete_session_token_by_token_hash(db, hash_session_token(token)) + return diff --git a/app/services/delivery.py b/app/services/delivery.py index 2661fbe..ef3d89e 100644 --- a/app/services/delivery.py +++ b/app/services/delivery.py @@ -6,7 +6,6 @@ from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from app.core.i18n import load_translations from app.database.models import ( @@ -18,8 +17,12 @@ Destination, FailureType, Form, - Submission, ) +from app.database.queries.deliveries import ( + get_accessible_delivery_by_id, + get_delivery_for_processing, +) +from app.exceptions import DeliveryNotFoundError, DeliveryNotReplayableError from app.services.telegram import format_submission_message, send_telegram_alert MAX_DELIVERY_ATTEMPTS = 5 @@ -63,20 +66,6 @@ async def claim_next_delivery(db: AsyncSession) -> Delivery | None: return delivery -async def get_delivery_for_processing( - db: AsyncSession, delivery_id: int -) -> Delivery | None: - result = await db.execute( - select(Delivery) - .where(Delivery.id == delivery_id) - .options( - selectinload(Delivery.destination), - selectinload(Delivery.submission).selectinload(Submission.form), - ) - ) - return result.scalar_one_or_none() - - async def start_attempt(db: AsyncSession, delivery: Delivery): delivery_attempt = DeliveryAttempt( delivery=delivery, trigger=delivery.queued_trigger @@ -84,7 +73,6 @@ async def start_attempt(db: AsyncSession, delivery: Delivery): delivery.attempt_count += 1 db.add(delivery_attempt) await db.commit() - await db.refresh(delivery_attempt) return delivery_attempt @@ -163,7 +151,7 @@ async def finish_delivery_attempt( log.info("Delivery result unknown") try: - await db.commit() + await db.flush() except IntegrityError: await db.rollback() log.exception("Failed to commit delivery attempt") @@ -283,14 +271,8 @@ async def process_delivery(delivery_id: int, db: AsyncSession) -> None: raise -class DeliveryNotFoundError(Exception): ... - - -class DeliveryNotReplayableError(Exception): ... - - -async def manual_delivery(delivery_id, db: AsyncSession): - delivery = await db.get(Delivery, delivery_id) +async def queue_manual_replay(db: AsyncSession, delivery_id, user_id) -> Delivery: + delivery = await get_accessible_delivery_by_id(db, delivery_id, user_id) if delivery is None: raise DeliveryNotFoundError @@ -305,6 +287,5 @@ async def manual_delivery(delivery_id, db: AsyncSession): delivery.failure_type = None delivery.next_retry_at = None delivery.processing_started_at = None - await db.commit() - await db.refresh(delivery) + await db.flush() return delivery diff --git a/app/services/forms.py b/app/services/forms.py index e7c6037..957b4f6 100644 --- a/app/services/forms.py +++ b/app/services/forms.py @@ -1,10 +1,12 @@ import enum +import uuid from pydantic import BaseModel -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import Destination, Form, Workspace +from app.database.models import Destination, Form +from app.database.queries.workspace import get_accessible_workspace_by_id +from app.exceptions import WorkspaceNotFoundError class DestinationType(str, enum.Enum): @@ -20,13 +22,22 @@ class FormCreate(BaseModel): title: str language: str = "en" destinations: list[DestinationCreate] + workspace_id: uuid.UUID async def create_form( - db: AsyncSession, title: str, language: str, destinations: list[DestinationCreate] -): - wp = await db.execute(select(Workspace)) - form = Form(title=title, language=language, workspace=wp.scalar_one()) + db: AsyncSession, + title: str, + language: str, + destinations: list[DestinationCreate], + user_id, + workspace_id, +) -> Form: + workspace = await get_accessible_workspace_by_id(db, user_id, workspace_id) + if workspace is None: + raise WorkspaceNotFoundError + + form = Form(title=title, language=language, workspace=workspace) for destination_data in destinations: destination = Destination( diff --git a/app/services/workspace.py b/app/services/workspace.py new file mode 100644 index 0000000..9ad57a0 --- /dev/null +++ b/app/services/workspace.py @@ -0,0 +1,26 @@ +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User, Workspace +from app.database.queries.workspace import ( + get_user_workspaces, + get_workspace_membership_by_user_and_workspace_id, +) +from app.exceptions import WorkspaceAccessDeniedError + + +async def get_user_workspace( + db: AsyncSession, user: User, workspace_id: uuid.UUID +) -> Workspace: + workspace_membership = await get_workspace_membership_by_user_and_workspace_id( + db, user.id, workspace_id + ) + if workspace_membership is None: + raise WorkspaceAccessDeniedError + + return workspace_membership.workspace + + +async def list_user_workspaces(db: AsyncSession, user: User) -> list[Workspace]: + return await get_user_workspaces(db, user.id) diff --git a/pyproject.toml b/pyproject.toml index b43f4b4..cd7b11b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "alembic>=1.19.1", + "argon2-cffi>=25.1.0", "asyncpg>=0.31.0", "fastapi>=0.141.1", "httpx>=0.28.1", @@ -12,6 +13,7 @@ dependencies = [ "pydantic>=2.13.5", "pydantic-settings>=2.15.0", "python-multipart>=0.0.32", + "sqlalchemy>=2.0.52", "starlette>=1.6.0", "uvicorn>=0.52.4", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..049e427 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,239 @@ +import os + +import pytest +from dotenv import load_dotenv +from httpx import ASGITransport, AsyncClient +from sqlalchemy import NullPool +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.database.models import ( + Base, + Delivery, + DeliveryStatus, + DeliveryTrigger, + Destination, + FailureType, + Form, + Submission, + User, + Workspace, + WorkspaceMembership, + WorkspaceRole, +) +from app.database.queries.auth import get_user_by_email +from app.database.session import get_db +from app.main import app + +load_dotenv(".env.test") + +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") + +assert TEST_DATABASE_URL is not None +assert "test" in TEST_DATABASE_URL + +engine = create_async_engine( + TEST_DATABASE_URL, + poolclass=NullPool, +) + +TestSessionLocal = async_sessionmaker( + engine, + expire_on_commit=False, + class_=AsyncSession, +) + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + + async with AsyncClient( + transport=transport, + base_url="https://test", + ) as client: + yield client + + +@pytest.fixture(autouse=True) +async def prepare_database(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def override_get_db(): + async with TestSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + app.dependency_overrides[get_db] = override_get_db + + yield + + app.dependency_overrides.clear() + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +async def db(): + async with TestSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def create_form( + db: AsyncSession, + *, + title: str = "Test form", + language: str = "en", + workspace: Workspace | None = None, + created_at=None, +) -> Form: + workspace = Workspace(name="Test Workspace") if workspace is None else workspace + form = Form(title=title, language=language, workspace=workspace) + + if created_at is not None: + form.created_at = created_at + + return form + + +async def create_delivery( + db: AsyncSession, + status: DeliveryStatus = DeliveryStatus.PENDING, + workspace: Workspace | None = None, +) -> Delivery: + form = await create_form(db, workspace=workspace) + destination = Destination( + form=form, + type="telegram", + reference="123456789", + ) + submission = Submission( + form=form, + payload={"email": "test@example.com"}, + ) + delivery = Delivery( + submission=submission, + destination=destination, + status=status, + failure_type=( + FailureType.PERMANENT if status == DeliveryStatus.FAILED else None + ), + queued_trigger=DeliveryTrigger.AUTOMATIC, + ) + + db.add(form) + await db.commit() + return delivery + + +async def create_user( + db: AsyncSession, + *, + email: str = "user@example.com", +) -> User: + user = User(email=email) + db.add(user) + await db.flush() + return user + + +async def create_workspace( + db: AsyncSession, + *, + name: str = "Test Workspace", +) -> Workspace: + workspace = Workspace(name=name) + db.add(workspace) + await db.flush() + return workspace + + +async def create_membership( + db: AsyncSession, + *, + user: User, + workspace: Workspace, + role: WorkspaceRole = WorkspaceRole.MEMBER, +) -> WorkspaceMembership: + membership = WorkspaceMembership( + user=user, + workspace=workspace, + role=role, + ) + db.add(membership) + await db.flush() + return membership + + +async def register_and_get_user( + client: AsyncClient, + db: AsyncSession, + *, + email: str = "user@example.com", + password: str = "strong-password", +) -> User: + response = await client.post( + "/api/auth/register", + json={ + "email": email, + "password": password, + }, + ) + + assert response.status_code == 201 + + user = await get_user_by_email(db, email) + assert user is not None + + return user + + +async def create_submission( + db: AsyncSession, + *, + form: Form, + payload: dict | None = None, +) -> Submission: + submission = Submission( + form=form, + payload=payload or {"name": "Test User"}, + ) + db.add(submission) + await db.flush() + + return submission + + +async def create_authenticated_workspace( + client: AsyncClient, + db: AsyncSession, + *, + email: str = "user@example.com", + role: WorkspaceRole = WorkspaceRole.OWNER, +) -> tuple[User, Workspace]: + user = await register_and_get_user( + client, + db, + email=email, + ) + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=role, + ) + + return user, workspace diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..78492df --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,713 @@ +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Session, User +from app.services.auth import ( + InvalidCredentialsError, + UserAlreadyExistsError, + create_session, + generate_session_token, + get_user_by_session_token, + hash_password, + hash_session_token, + login_user, + logout_session, + register_user, + verify_password, +) + + +def test_hash_password_does_not_store_plaintext(): + password = "correct-horse-battery-staple" + + password_hash = hash_password(password) + + assert password_hash != password + assert password not in password_hash + + +def test_hash_password_uses_different_salt_each_time(): + password = "same-password" + + first_hash = hash_password(password) + second_hash = hash_password(password) + + assert first_hash != second_hash + + +def test_verify_password_accepts_correct_password(): + password = "very-secret-password" + password_hash = hash_password(password) + + assert verify_password(password, password_hash) is True + + +def test_verify_password_rejects_wrong_password(): + password_hash = hash_password("correct-password") + + assert verify_password("wrong-password", password_hash) is False + + +def test_verify_password_rejects_invalid_hash(): + assert verify_password("some-password", "not-an-argon2-hash") is False + + +def test_generate_session_token_returns_unique_tokens(): + first_token = generate_session_token() + second_token = generate_session_token() + + assert first_token != second_token + + +def test_generate_session_token_is_not_empty(): + token = generate_session_token() + + assert isinstance(token, str) + assert token + + +def test_hash_session_token_is_deterministic(): + token = generate_session_token() + + first_hash = hash_session_token(token) + second_hash = hash_session_token(token) + + assert first_hash == second_hash + + +def test_hash_session_token_returns_sha256_hex_digest(): + token = generate_session_token() + + token_hash = hash_session_token(token) + + assert len(token_hash) == 64 + + # SHA-256 hex digest may contain only hexadecimal characters. + int(token_hash, 16) + + +def test_different_session_tokens_have_different_hashes(): + first_token = generate_session_token() + second_token = generate_session_token() + + assert hash_session_token(first_token) != hash_session_token(second_token) + + +def test_session_token_is_not_stored_as_its_hash(): + token = generate_session_token() + + token_hash = hash_session_token(token) + + assert token_hash != token + assert token not in token_hash + + +async def test_create_session_stores_hashed_token(db: AsyncSession): + user = User(email="test@example.com") + db.add(user) + await db.flush() + + token = await create_session(db, user) + + result = await db.execute(select(Session).where(Session.user_id == user.id)) + session = result.scalar_one() + + assert token + assert session.token_hash != token + assert session.token_hash == hash_session_token(token) + + +async def test_create_session_sets_expiration(db: AsyncSession): + user = User(email="test@example.com") + db.add(user) + await db.flush() + + before = datetime.now(UTC) + + await create_session(db, user) + + after = datetime.now(UTC) + + result = await db.execute(select(Session).where(Session.user_id == user.id)) + session = result.scalar_one() + + assert before + timedelta(days=30) <= session.expires_at + assert session.expires_at <= after + timedelta(days=30) + + +async def test_get_user_by_session_token_returns_user(db: AsyncSession): + user = User(email="test@example.com") + db.add(user) + await db.flush() + + token = await create_session(db, user) + + result = await get_user_by_session_token(db, token) + + assert result is not None + assert result.id == user.id + + +async def test_get_user_by_session_token_returns_none_for_unknown_token( + db: AsyncSession, +): + result = await get_user_by_session_token( + db, + "this-is-not-a-real-session-token", + ) + + assert result is None + + +async def test_get_user_by_session_token_rejects_expired_session( + db: AsyncSession, +): + user = User(email="test@example.com") + db.add(user) + await db.flush() + + token = generate_session_token() + + session = Session( + user=user, + token_hash=hash_session_token(token), + expires_at=datetime.now(UTC) - timedelta(seconds=1), + ) + db.add(session) + await db.flush() + + result = await get_user_by_session_token(db, hash_session_token(token)) + + assert result is None + + +async def test_register_user_creates_user(db: AsyncSession): + user = await register_user( + db, + "test@example.com", + "strong-password", + ) + + stored_user = await db.scalar(select(User).where(User.id == user.id)) + + assert stored_user is not None + assert stored_user.email == "test@example.com" + + +async def test_register_user_normalizes_email(db: AsyncSession): + user = await register_user( + db, + " Test@Example.COM ", + "strong-password", + ) + + assert user.email == "test@example.com" + + +async def test_register_user_hashes_password(db: AsyncSession): + password = "strong-password" + + user = await register_user( + db, + "test@example.com", + password, + ) + + assert user.password_hash is not None + assert user.password_hash != password + assert verify_password(password, user.password_hash) is True + + +async def test_register_user_rejects_duplicate_email(db: AsyncSession): + await register_user( + db, + "test@example.com", + "first-password", + ) + + with pytest.raises(UserAlreadyExistsError): + await register_user( + db, + "test@example.com", + "second-password", + ) + + +async def test_register_user_rejects_duplicate_normalized_email( + db: AsyncSession, +): + await register_user( + db, + "Test@Example.COM", + "first-password", + ) + + with pytest.raises(UserAlreadyExistsError): + await register_user( + db, + " test@example.com ", + "second-password", + ) + + +async def test_login_user_returns_user_and_session_token(db: AsyncSession): + password = "strong-password" + + user = await register_user( + db, + "test@example.com", + password, + ) + + user, token = await login_user( + db, + "test@example.com", + password, + ) + + assert isinstance(token, str) + assert isinstance(user, User) + assert token + + session = await db.scalar(select(Session).where(Session.user_id == user.id)) + + assert session is not None + assert session.token_hash == hash_session_token(token) + + +async def test_login_user_normalizes_email(db: AsyncSession): + password = "strong-password" + + await register_user( + db, + "test@example.com", + password, + ) + + token = await login_user( + db, + " Test@Example.COM ", + password, + ) + + assert token + + +async def test_login_user_rejects_wrong_password(db: AsyncSession): + await register_user( + db, + "test@example.com", + "correct-password", + ) + + with pytest.raises(InvalidCredentialsError): + await login_user( + db, + "test@example.com", + "wrong-password", + ) + + +async def test_login_user_rejects_unknown_email(db: AsyncSession): + with pytest.raises(InvalidCredentialsError): + await login_user( + db, + "missing@example.com", + "some-password", + ) + + +async def test_login_user_rejects_user_without_password(db: AsyncSession): + user = User( + email="oauth@example.com", + password_hash=None, + ) + db.add(user) + await db.flush() + + with pytest.raises(InvalidCredentialsError): + await login_user( + db, + "oauth@example.com", + "some-password", + ) + + +async def test_logout_session_deletes_session(db: AsyncSession): + user = await register_user( + db, + "test@example.com", + "strong-password", + ) + token = await create_session(db, user) + + await logout_session(db, token) + + session = await db.scalar( + select(Session).where(Session.token_hash == hash_session_token(token)) + ) + + assert session is None + + +async def test_logout_session_does_not_delete_other_sessions( + db: AsyncSession, +): + user = await register_user( + db, + "test@example.com", + "strong-password", + ) + + first_token = await create_session(db, user) + second_token = await create_session(db, user) + + await logout_session(db, first_token) + + first_session = await db.scalar( + select(Session).where(Session.token_hash == hash_session_token(first_token)) + ) + second_session = await db.scalar( + select(Session).where(Session.token_hash == hash_session_token(second_token)) + ) + + assert first_session is None + assert second_session is not None + + +async def test_logout_session_accepts_unknown_token( + db: AsyncSession, +): + await logout_session( + db, + "unknown-session-token", + ) + + +async def test_register_endpoint_creates_user(client): + response = await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + assert response.status_code == 201 + + data = response.json() + + assert data["email"] == "test@example.com" + assert data["email_verified"] is False + assert "id" in data + assert "password_hash" not in data + + +async def test_register_endpoint_sets_session_cookie(client): + response = await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + assert response.status_code == 201 + assert "ackvia_session" in response.cookies + + cookie = response.headers["set-cookie"] + + assert "HttpOnly" in cookie + assert "Secure" in cookie + assert "SameSite=lax" in cookie + + +async def test_register_endpoint_normalizes_email(client): + response = await client.post( + "/api/auth/register", + json={ + "email": " Test@Example.COM ", + "password": "strong-password", + }, + ) + + assert response.status_code == 201 + assert response.json()["email"] == "test@example.com" + + +async def test_register_endpoint_rejects_duplicate_email(client): + first = await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "first-password", + }, + ) + + second = await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "second-password", + }, + ) + + assert first.status_code == 201 + assert second.status_code == 409 + + +async def test_register_endpoint_rejects_duplicate_normalized_email(client): + first = await client.post( + "/api/auth/register", + json={ + "email": "Test@Example.COM", + "password": "first-password", + }, + ) + + second = await client.post( + "/api/auth/register", + json={ + "email": " test@example.com ", + "password": "second-password", + }, + ) + + assert first.status_code == 201 + assert second.status_code == 409 + + +async def test_login_endpoint_returns_user(client): + await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + response = await client.post( + "/api/auth/login", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + assert response.status_code == 200 + + data = response.json() + + assert data["email"] == "test@example.com" + assert data["email_verified"] is False + assert "id" in data + assert "password_hash" not in data + + +async def test_login_endpoint_sets_session_cookie(client): + await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + response = await client.post( + "/api/auth/login", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + assert response.status_code == 200 + assert "ackvia_session" in response.cookies + + cookie = response.headers["set-cookie"] + + assert "HttpOnly" in cookie + assert "Secure" in cookie + assert "SameSite=lax" in cookie + + +async def test_login_endpoint_normalizes_email(client): + await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + response = await client.post( + "/api/auth/login", + json={ + "email": " Test@Example.COM ", + "password": "strong-password", + }, + ) + + assert response.status_code == 200 + assert response.json()["email"] == "test@example.com" + + +async def test_login_endpoint_rejects_wrong_password(client): + await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "correct-password", + }, + ) + + response = await client.post( + "/api/auth/login", + json={ + "email": "test@example.com", + "password": "wrong-password", + }, + ) + + assert response.status_code == 401 + + +async def test_login_endpoint_rejects_unknown_email(client): + response = await client.post( + "/api/auth/login", + json={ + "email": "missing@example.com", + "password": "some-password", + }, + ) + + assert response.status_code == 401 + + +async def test_me_returns_authenticated_user(client): + register_response = await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + assert register_response.status_code == 201 + + response = await client.get("/api/auth/me") + + assert response.status_code == 200 + + data = response.json() + + assert data["email"] == "test@example.com" + assert data["email_verified"] is False + assert "id" in data + assert "password_hash" not in data + + +async def test_me_rejects_request_without_session_cookie(client): + client.cookies.clear() + + response = await client.get("/api/auth/me") + + assert response.status_code == 401 + + +async def test_me_rejects_invalid_session_cookie(client): + client.cookies.set( + "ackvia_session", + "invalid-session-token", + ) + + response = await client.get("/api/auth/me") + + assert response.status_code == 401 + + +async def test_me_rejects_expired_session(client, db: AsyncSession): + user = User( + email="expired@example.com", + password_hash=hash_password("strong-password"), + ) + db.add(user) + await db.commit() + + token = generate_session_token() + + session = Session( + user=user, + token_hash=hash_session_token(token), + expires_at=datetime.now(UTC) - timedelta(seconds=1), + ) + db.add(session) + await db.commit() + + client.cookies.set( + "ackvia_session", + token, + ) + + response = await client.get("/api/auth/me") + + assert response.status_code == 401 + + +async def test_logout_removes_session(client): + await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + before = await client.get("/api/auth/me") + assert before.status_code == 200 + + response = await client.post("/api/auth/logout") + + assert response.status_code == 204 + + after = await client.get("/api/auth/me") + assert after.status_code == 401 + + +async def test_logout_clears_session_cookie(client): + await client.post( + "/api/auth/register", + json={ + "email": "test@example.com", + "password": "strong-password", + }, + ) + + assert "ackvia_session" in client.cookies + + response = await client.post("/api/auth/logout") + + assert response.status_code == 204 + assert "ackvia_session" not in client.cookies + + +async def test_logout_without_session_is_idempotent(client): + client.cookies.clear() + + first = await client.post("/api/auth/logout") + second = await client.post("/api/auth/logout") + + assert first.status_code == 204 + assert second.status_code == 204 + + +async def test_logout_with_invalid_session_is_idempotent(client): + client.cookies.set("invalid_session_token", "ackvia_session", path="/") + + response = await client.post("/api/auth/logout") + + assert response.status_code == 204 + assert "ackvia_session" not in client.cookies diff --git a/tests/test_flow.py b/tests/test_deliveries.py similarity index 50% rename from tests/test_flow.py rename to tests/test_deliveries.py index 6c475bf..4e06839 100644 --- a/tests/test_flow.py +++ b/tests/test_deliveries.py @@ -1,21 +1,13 @@ -import os -import uuid from datetime import datetime, timedelta, timezone import pytest -from dotenv import load_dotenv -from httpx import ASGITransport, AsyncClient -from sqlalchemy import NullPool, select +from httpx import AsyncClient +from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import ( - AsyncSession, - async_sessionmaker, - create_async_engine, -) +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import ( - Base, Delivery, DeliveryAttempt, DeliveryAttemptResult, @@ -25,161 +17,30 @@ FailureType, Form, Submission, - Workspace, + WorkspaceRole, ) -from app.database.session import get_db -from app.main import app +from app.database.queries.deliveries import get_accessible_delivery_by_id from app.services.delivery import ( MAX_DELIVERY_ATTEMPTS, DeliveryNotFoundError, - DeliveryNotReplayableError, claim_next_delivery, finish_delivery_attempt, - manual_delivery, process_delivery, + queue_manual_replay, ) from app.workers.delivery import recovery_stale_deliveries - -load_dotenv(".env.test") - -TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") - -assert TEST_DATABASE_URL is not None -assert "test" in TEST_DATABASE_URL - -engine = create_async_engine( - TEST_DATABASE_URL, - poolclass=NullPool, -) - -TestSessionLocal = async_sessionmaker( - engine, - expire_on_commit=False, - class_=AsyncSession, +from tests.conftest import ( + create_authenticated_workspace, + create_delivery, + create_form, + create_membership, + create_submission, + create_user, + create_workspace, + register_and_get_user, ) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -async def client(): - transport = ASGITransport(app=app) - - async with AsyncClient( - transport=transport, - base_url="http://test", - ) as client: - yield client - - -@pytest.fixture(autouse=True) -async def prepare_database(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - async def override_get_db(): - async with TestSessionLocal() as session: - yield session - - app.dependency_overrides[get_db] = override_get_db - - yield - - app.dependency_overrides.clear() - - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - - -@pytest.fixture -async def db(): - async with TestSessionLocal() as session: - yield session - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -async def create_form( - db: AsyncSession, - *, - title: str = "Test form", - language: str = "en", - created_at: datetime | None = None, -) -> Form: - workspace = Workspace(name="Test Workspace") - - form = Form( - title=title, - language=language, - workspace=workspace, - ) - - if created_at is not None: - form.created_at = created_at - - return form - - -async def create_delivery( - db: AsyncSession, - status: DeliveryStatus = DeliveryStatus.PENDING, -) -> Delivery: - form = await create_form(db) - - destination = Destination( - form=form, - type="telegram", - reference="123456789", - ) - - submission = Submission( - form=form, - payload={"email": "test@example.com"}, - ) - - delivery = Delivery( - submission=submission, - destination=destination, - status=status, - failure_type=( - FailureType.PERMANENT if status == DeliveryStatus.FAILED else None - ), - queued_trigger=DeliveryTrigger.AUTOMATIC, - ) - - db.add(form) - await db.commit() - - return delivery - - -# --------------------------------------------------------------------------- -# Form API -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_invalid_form_uuid(client: AsyncClient): - random_id = uuid.uuid4() - - response = await client.post( - f"/f/{random_id}", - json={"dummy": "data"}, - headers={"Accept": "application/json"}, - ) - - assert response.status_code == 404 - - -# --------------------------------------------------------------------------- -# Delivery creation -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_delivery_creation(db: AsyncSession): form = await create_form( @@ -304,11 +165,6 @@ async def test_duplicate_delivery_failure(db: AsyncSession): assert deliveries == [] -# --------------------------------------------------------------------------- -# Delivery states -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_delivery_states_are_independent( db: AsyncSession, @@ -407,11 +263,6 @@ async def test_delivery_status_is_persisted( assert stored_delivery.status == status -# --------------------------------------------------------------------------- -# Retry selection -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_awaiting_retry_delivery_can_be_selected_when_due( db: AsyncSession, @@ -795,802 +646,510 @@ async def test_recovered_delivery_can_be_claimed(db: AsyncSession): @pytest.mark.asyncio -async def test_get_form_submissions_returns_only_its_submissions( - db: AsyncSession, - client: AsyncClient, -): - form = await create_form(db, title="Test form") - - submission_1 = Submission( - form=form, - payload={"name": "Ivan"}, +async def test_get_delivery_returns_delivery(db: AsyncSession, client): + user = await register_and_get_user( + client, + db, ) - submission_2 = Submission( - form=form, - payload={"name": "Petro"}, + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, ) + form = await create_form( + db, + workspace=workspace, + ) db.add(form) await db.commit() - response = await client.get(f"/api/forms/{form.id}/submissions") + delivery = await create_delivery(db, workspace=workspace) + response = await client.get(f"/api/deliveries/{delivery.id}") assert response.status_code == 200 - - data = response.json() - - assert len(data) == 2 - assert {item["id"] for item in data} == { - submission_1.id, - submission_2.id, - } + assert response.json()["id"] == delivery.id + assert response.json()["attempts"] == [] @pytest.mark.asyncio -async def test_get_form_submissions_does_not_return_other_form_submissions( - db: AsyncSession, - client: AsyncClient, -): - form_1 = await create_form(db, title="Form 1") - form_2 = await create_form(db, title="Form 2") - - submission_1 = Submission( - form=form_1, - payload={"name": "Ivan"}, +async def test_get_delivery_returns_attempts(db: AsyncSession, client): + user = await register_and_get_user( + client, + db, ) - submission_2 = Submission( - form=form_2, - payload={"name": "Petro"}, + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, ) - db.add_all([form_1, form_2]) + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) await db.commit() - response = await client.get(f"/api/forms/{form_1.id}/submissions") - - assert response.status_code == 200 + delivery = await create_delivery(db, DeliveryStatus.PROCESSING, workspace=workspace) + attempt = DeliveryAttempt( + delivery=delivery, + result=DeliveryAttemptResult.RETRYABLE_FAILURE, + error="Telegram unavailable", + trigger=DeliveryTrigger.AUTOMATIC, + finished_at=datetime.now(timezone.utc), + ) + delivery.attempt_count = 1 + db.add(attempt) + await db.commit() + response = await client.get(f"/api/deliveries/{delivery.id}") data = response.json() - assert len(data) == 1 - assert data[0]["id"] == submission_1.id - assert data[0]["id"] != submission_2.id + assert response.status_code == 200 + assert data["attempt_count"] == 1 + assert ( + data["attempts"][0]["result"] == DeliveryAttemptResult.RETRYABLE_FAILURE.value + ) @pytest.mark.asyncio -async def test_get_form_submissions_are_ordered_newest_first( - db: AsyncSession, - client: AsyncClient, -): - form = await create_form(db, title="Test form") - - old_submission = Submission( - form=form, - payload={"name": "Old"}, - created_at=datetime.now(timezone.utc) - timedelta(hours=2), - ) - new_submission = Submission( - form=form, - payload={"name": "New"}, - created_at=datetime.now(timezone.utc), +async def test_get_delivery_returns_404_when_not_found(client, db: AsyncSession): + await create_authenticated_workspace( + client, + db, ) - db.add(form) await db.commit() - - response = await client.get(f"/api/forms/{form.id}/submissions") - - assert response.status_code == 200 - - data = response.json() - - assert len(data) == 2 - assert data[0]["id"] == new_submission.id - assert data[1]["id"] == old_submission.id + response = await client.get("/api/deliveries/999999") + assert response.status_code == 404 -@pytest.mark.asyncio -async def test_get_form_submissions_respects_limit( +async def test_get_accessible_delivery_by_id_returns_delivery_for_member( db: AsyncSession, - client: AsyncClient, ): - form = await create_form(db, title="Test form") + user = await create_user(db) + workspace = await create_workspace(db) - for i in range(5): - Submission( - form=form, - payload={"number": i}, - ) + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + form = await create_form(db, workspace=workspace) db.add(form) - await db.commit() + await db.flush() - response = await client.get( - f"/api/forms/{form.id}/submissions", - params={"limit": 2}, + submission = await create_submission( + db, + form=form, ) - assert response.status_code == 200 + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() - data = response.json() + delivery = Delivery( + submission=submission, + destination=destination, + ) + db.add(delivery) + await db.flush() + + result = await get_accessible_delivery_by_id( + db, + delivery.id, + user.id, + ) - assert len(data) == 2 + assert result is not None + assert result.id == delivery.id -@pytest.mark.asyncio -async def test_get_form_submissions_respects_offset( +async def test_get_accessible_delivery_by_id_returns_none_for_foreign_workspace( db: AsyncSession, - client: AsyncClient, ): - form = await create_form(db, title="Test form") - - now = datetime.now(timezone.utc) - - submissions = [ - Submission( - form=form, - payload={"number": i}, - created_at=now + timedelta(seconds=i), - ) - for i in range(5) - ] + user = await create_user(db) - db.add(form) - await db.commit() - - response = await client.get( - f"/api/forms/{form.id}/submissions", - params={ - "limit": 2, - "offset": 2, - }, + foreign_workspace = await create_workspace( + db, + name="Foreign", ) - assert response.status_code == 200 - - data = response.json() + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() - assert len(data) == 2 - assert data[0]["id"] == submissions[2].id - assert data[1]["id"] == submissions[1].id + submission = await create_submission( + db, + form=foreign_form, + ) + destination = Destination( + form=foreign_form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() -@pytest.mark.asyncio -async def test_get_form_submissions_returns_404_for_unknown_form( - client: AsyncClient, -): - form_id = uuid.uuid4() + delivery = Delivery( + submission=submission, + destination=destination, + ) + db.add(delivery) + await db.flush() - response = await client.get(f"/api/forms/{form_id}/submissions") + result = await get_accessible_delivery_by_id( + db, + delivery.id, + user.id, + ) - assert response.status_code == 404 + assert result is None -@pytest.mark.asyncio -async def test_get_submission_returns_submission_with_payload( +async def test_get_accessible_delivery_by_id_returns_none_for_unknown_delivery( db: AsyncSession, - client: AsyncClient, ): - delivery = await create_delivery(db, DeliveryStatus.PENDING) - submission = delivery.submission - - response = await client.get(f"/api/submissions/{submission.id}") + user = await create_user(db) - assert response.status_code == 200 - - data = response.json() + result = await get_accessible_delivery_by_id( + db, + 999999999, + user.id, + ) - assert data["id"] == submission.id - assert data["payload"] == {"email": "test@example.com"} - assert "created_at" in data + assert result is None -@pytest.mark.asyncio -async def test_get_submission_returns_deliveries( +async def test_get_delivery_returns_accessible_delivery( db: AsyncSession, client: AsyncClient, ): - delivery = await create_delivery(db, DeliveryStatus.PENDING) - submission = delivery.submission - - response = await client.get(f"/api/submissions/{submission.id}") - - assert response.status_code == 200 - - data = response.json() + user = await register_and_get_user(client, db) - assert len(data["deliveries"]) == 1 + workspace = await create_workspace(db) - stored_delivery = data["deliveries"][0] + await create_membership( + db, + user=user, + workspace=workspace, + ) - assert stored_delivery["id"] == delivery.id - assert stored_delivery["destination_id"] == delivery.destination_id - assert stored_delivery["status"] == DeliveryStatus.PENDING.value - assert stored_delivery["attempt_count"] == 0 + form = await create_form(db, workspace=workspace) + db.add(form) + await db.flush() + submission = await create_submission(db, form=form) -@pytest.mark.asyncio -async def test_get_submission_returns_delivery_state( - db: AsyncSession, - client: AsyncClient, -): - delivery = await create_delivery(db, DeliveryStatus.AWAITING_RETRY) + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() - delivery.attempt_count = 2 - delivery.last_error = "Telegram unavailable" - delivery.next_retry_at = datetime.now(timezone.utc) + timedelta(minutes=1) + delivery = Delivery( + submission=submission, + destination=destination, + ) + db.add(delivery) await db.commit() - response = await client.get(f"/api/submissions/{delivery.submission_id}") + response = await client.get(f"/api/deliveries/{delivery.id}") assert response.status_code == 200 - - data = response.json() - stored_delivery = data["deliveries"][0] - - assert stored_delivery["status"] == DeliveryStatus.AWAITING_RETRY.value - assert stored_delivery["attempt_count"] == 2 - assert stored_delivery["last_error"] == "Telegram unavailable" - assert stored_delivery["next_retry_at"] is not None - assert stored_delivery["delivered_at"] is None + assert response.json()["id"] == delivery.id @pytest.mark.asyncio -async def test_get_submission_not_found( +async def test_get_delivery_hides_foreign_workspace_delivery( + db: AsyncSession, client: AsyncClient, ): - response = await client.get("/api/submissions/999999") - - assert response.status_code == 404 + await create_authenticated_workspace(client, db) + foreign_workspace = await create_workspace(db, name="Foreign") + delivery = await create_delivery( + db, + workspace=foreign_workspace, + ) -@pytest.mark.asyncio -async def test_get_submission_invalid_id( - client: AsyncClient, -): - response = await client.get("/api/submissions/not-an-id") + response = await client.get(f"/api/deliveries/{delivery.id}") - assert response.status_code == 422 + assert response.status_code == 404 @pytest.mark.asyncio -async def test_get_forms_returns_forms( +async def test_get_delivery_requires_authentication( db: AsyncSession, client: AsyncClient, ): - form_1 = await create_form(db, title="Form 1") - form_2 = await create_form(db, title="Form 2") - - db.add_all([form_1, form_2]) - await db.commit() - - response = await client.get("/api/forms") + workspace = await create_workspace(db) + delivery = await create_delivery( + db, + workspace=workspace, + ) - assert response.status_code == 200 + client.cookies.clear() - data = response.json() + response = await client.get(f"/api/deliveries/{delivery.id}") - assert len(data) == 2 - assert {item["id"] for item in data} == { - str(form_1.id), - str(form_2.id), - } + assert response.status_code == 401 -@pytest.mark.asyncio -async def test_get_forms_are_ordered_newest_first( +async def test_manual_replay_allows_workspace_member( db: AsyncSession, - client: AsyncClient, ): - now = datetime.now(timezone.utc) + user = await create_user(db) + workspace = await create_workspace(db) - old_form = await create_form( + await create_membership( db, - title="Old form", - created_at=now - timedelta(hours=1), + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, ) - new_form = await create_form( + + form = await create_form( db, - title="New form", - created_at=now, + workspace=workspace, ) + db.add(form) + await db.flush() - db.add_all([old_form, new_form]) - await db.commit() - - response = await client.get("/api/forms") - - assert response.status_code == 200 - - data = response.json() - - assert len(data) == 2 - assert data[0]["id"] == str(new_form.id) - assert data[1]["id"] == str(old_form.id) - - -@pytest.mark.asyncio -async def test_get_forms_respects_limit_and_offset( - db: AsyncSession, - client: AsyncClient, -): - now = datetime.now(timezone.utc) - - forms = [ - await create_form( - db, - title=f"Form {i}", - created_at=now + timedelta(seconds=i), - ) - for i in range(5) - ] - - db.add_all(forms) - await db.commit() - - response = await client.get( - "/api/forms", - params={ - "limit": 2, - "offset": 1, - }, - ) - - assert response.status_code == 200 - - data = response.json() - - assert len(data) == 2 - assert data[0]["id"] == str(forms[3].id) - assert data[1]["id"] == str(forms[2].id) - - -@pytest.mark.asyncio -async def test_get_forms_returns_empty_list_when_no_forms_exist( - client: AsyncClient, -): - response = await client.get("/api/forms") - - assert response.status_code == 200 - assert response.json() == [] - - -@pytest.mark.asyncio -async def test_get_delivery_returns_delivery( - db: AsyncSession, - client: AsyncClient, -): - delivery = await create_delivery(db, DeliveryStatus.PENDING) - - response = await client.get(f"/api/deliveries/{delivery.id}") - - assert response.status_code == 200 - - data = response.json() - - assert data["id"] == delivery.id - assert data["submission_id"] == delivery.submission_id - assert data["destination_id"] == delivery.destination_id - assert data["status"] == DeliveryStatus.PENDING.value - assert data["attempt_count"] == 0 - assert data["attempts"] == [] - - -@pytest.mark.asyncio -async def test_get_delivery_returns_attempts( - db: AsyncSession, - client: AsyncClient, -): - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - - attempt = DeliveryAttempt( - delivery=delivery, - result=DeliveryAttemptResult.RETRYABLE_FAILURE, - error="Telegram unavailable", - trigger=DeliveryTrigger.AUTOMATIC, - finished_at=datetime.now(timezone.utc), - ) - - delivery.attempt_count = 1 - - db.add(attempt) - await db.commit() - - response = await client.get(f"/api/deliveries/{delivery.id}") - - assert response.status_code == 200 - - data = response.json() - - assert data["attempt_count"] == 1 - assert len(data["attempts"]) == 1 - - stored_attempt = data["attempts"][0] - - assert stored_attempt["id"] == attempt.id - assert stored_attempt["result"] == DeliveryAttemptResult.RETRYABLE_FAILURE.value - assert stored_attempt["error"] == "Telegram unavailable" - assert stored_attempt["trigger"] == DeliveryTrigger.AUTOMATIC.value - assert stored_attempt["finished_at"] is not None - - -@pytest.mark.asyncio -async def test_get_delivery_returns_attempts_in_chronological_order( - db: AsyncSession, - client: AsyncClient, -): - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - - now = datetime.now(timezone.utc) - - first_attempt = DeliveryAttempt( - delivery=delivery, - trigger=DeliveryTrigger.AUTOMATIC, - created_at=now - timedelta(minutes=2), + submission = await create_submission( + db, + form=form, ) - second_attempt = DeliveryAttempt( - delivery=delivery, - trigger=DeliveryTrigger.RETRY, - created_at=now - timedelta(minutes=1), + destination = Destination( + form=form, + type="telegram", + reference="123456", ) + db.add(destination) + await db.flush() - db.add_all([first_attempt, second_attempt]) - await db.commit() - - response = await client.get(f"/api/deliveries/{delivery.id}") - - assert response.status_code == 200 - - attempts = response.json()["attempts"] - - assert len(attempts) == 2 - assert attempts[0]["id"] == first_attempt.id - assert attempts[1]["id"] == second_attempt.id - - -@pytest.mark.asyncio -async def test_get_delivery_returns_404_when_not_found( - client: AsyncClient, -): - response = await client.get("/api/deliveries/999999") - - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_failed_delivery_can_be_queued_for_manual_replay( - db: AsyncSession, -): - delivery = await create_delivery( - db, + delivery = Delivery( + submission=submission, + destination=destination, status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, ) - delivery.attempt_count = 5 - delivery.next_retry_at = datetime.now(timezone.utc) - delivery.processing_started_at = datetime.now(timezone.utc) - await db.commit() - - result = await manual_delivery(delivery.id, db) + db.add(delivery) + await db.flush() - assert result is not None - assert result.status == DeliveryStatus.PENDING - assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY - assert result.failure_type is None - assert result.next_retry_at is None - assert result.processing_started_at is None - assert result.attempt_count == 5 - - -@pytest.mark.asyncio -async def test_unknown_delivery_can_be_queued_for_manual_replay( - db: AsyncSession, -): - delivery = await create_delivery( + result = await queue_manual_replay( db, - status=DeliveryStatus.UNKNOWN, + delivery.id, + user.id, ) - result = await manual_delivery(delivery.id, db) - - assert result is not None + assert result.id == delivery.id assert result.status == DeliveryStatus.PENDING assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY - assert result.failure_type is None -@pytest.mark.asyncio -async def test_manual_replay_raises_not_found_for_missing_delivery( +async def test_manual_replay_rejects_foreign_workspace_delivery( db: AsyncSession, ): - with pytest.raises(DeliveryNotFoundError): - await manual_delivery(999999, db) - + user = await create_user(db) -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status", - [ - DeliveryStatus.PENDING, - DeliveryStatus.PROCESSING, - DeliveryStatus.AWAITING_RETRY, - DeliveryStatus.SUCCEEDED, - ], -) -async def test_manual_replay_raises_not_replayable_for_invalid_status( - db: AsyncSession, - status: DeliveryStatus, -): - delivery = await create_delivery( + foreign_workspace = await create_workspace( db, - status=status, + name="Foreign", ) - with pytest.raises(DeliveryNotReplayableError): - await manual_delivery(delivery.id, db) - - await db.refresh(delivery) - - assert delivery.status == status - - -@pytest.mark.asyncio -async def test_manual_replay_not_replayable_preserves_delivery( - db: AsyncSession, -): - delivery = await create_delivery( + foreign_form = await create_form( db, - status=DeliveryStatus.SUCCEEDED, + workspace=foreign_workspace, ) + db.add(foreign_form) + await db.flush() - original_attempt_count = delivery.attempt_count - original_trigger = delivery.queued_trigger - - with pytest.raises(DeliveryNotReplayableError): - await manual_delivery(delivery.id, db) - - await db.refresh(delivery) - - assert delivery.status == DeliveryStatus.SUCCEEDED - assert delivery.attempt_count == original_attempt_count - assert delivery.queued_trigger == original_trigger - - -@pytest.mark.asyncio -async def test_replay_failed_delivery(client, db: AsyncSession): - delivery = await create_delivery( + submission = await create_submission( db, - status=DeliveryStatus.FAILED, + form=foreign_form, ) - response = await client.post(f"/api/deliveries/{delivery.id}/replay") + destination = Destination( + form=foreign_form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() - assert response.status_code == 202 + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + ) + db.add(delivery) + await db.flush() - data = response.json() - assert data["id"] == delivery.id - assert data["status"] == DeliveryStatus.PENDING.value + with pytest.raises(DeliveryNotFoundError): + await queue_manual_replay( + db, + delivery.id, + user.id, + ) - await db.refresh(delivery) - assert delivery.status == DeliveryStatus.PENDING - assert delivery.queued_trigger == DeliveryTrigger.MANUAL_REPLAY - assert delivery.failure_type is None - assert delivery.next_retry_at is None - assert delivery.processing_started_at is None +async def test_rejected_foreign_replay_preserves_delivery( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) -@pytest.mark.asyncio -async def test_replay_unknown_delivery(client, db: AsyncSession): - delivery = await create_delivery( + form = await create_form( db, - status=DeliveryStatus.UNKNOWN, + workspace=workspace, ) + db.add(form) + await db.flush() - response = await client.post(f"/api/deliveries/{delivery.id}/replay") - - assert response.status_code == 202 - - await db.refresh(delivery) - - assert delivery.status == DeliveryStatus.PENDING - assert delivery.queued_trigger == DeliveryTrigger.MANUAL_REPLAY - - -@pytest.mark.asyncio -async def test_replay_missing_delivery_returns_404(client): - response = await client.post("/api/deliveries/999999/replay") - - assert response.status_code == 404 - assert response.json()["detail"] == "Delivery not found" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status", - [ - DeliveryStatus.SUCCEEDED, - DeliveryStatus.PENDING, - DeliveryStatus.AWAITING_RETRY, - DeliveryStatus.PROCESSING, - ], -) -async def test_replay_non_replayable_delivery_returns_409( - client, - db: AsyncSession, - status: DeliveryStatus, -): - delivery = await create_delivery( + submission = await create_submission( db, - status=status, + form=form, ) - response = await client.post(f"/api/deliveries/{delivery.id}/replay") - - assert response.status_code == 409 - assert response.json()["detail"] == "Delivery is not replayable" - - await db.refresh(delivery) - - assert delivery.status == status - + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() -@pytest.mark.asyncio -async def test_manual_replay_is_recorded_in_attempt_history( - client, - db: AsyncSession, - monkeypatch, -): - delivery = await create_delivery( - db, + delivery = Delivery( + submission=submission, + destination=destination, status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + last_error="Telegram rejected request", ) + db.add(delivery) + await db.flush() - response = await client.post(f"/api/deliveries/{delivery.id}/replay") - - assert response.status_code == 202 + with pytest.raises(DeliveryNotFoundError): + await queue_manual_replay( + db, + delivery.id, + user.id, + ) - db.expire_all() + await db.refresh(delivery) - claimed = await claim_next_delivery(db) + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type == FailureType.PERMANENT + assert delivery.last_error == "Telegram rejected request" - assert claimed is not None - assert claimed.id == delivery.id - assert claimed.status == DeliveryStatus.PROCESSING - assert claimed.queued_trigger == DeliveryTrigger.MANUAL_REPLAY - async def fake_execute_delivery_attempt(destination, message): - return { - "success": True, - "external_reference": "manual-replay-test", - } +async def test_replay_foreign_delivery_returns_404( + db: AsyncSession, + client: AsyncClient, +): + await register_and_get_user(client, db) - monkeypatch.setattr( - "app.services.delivery.execute_delivery_attempt", - fake_execute_delivery_attempt, + foreign_workspace = await create_workspace( + db, + name="Foreign", ) - await process_delivery( - delivery_id=delivery.id, - db=db, + foreign_form = await create_form( + db, + workspace=foreign_workspace, ) + db.add(foreign_form) + await db.flush() - result = await db.execute( - select(DeliveryAttempt).where(DeliveryAttempt.delivery_id == delivery.id) + submission = await create_submission( + db, + form=foreign_form, ) - attempts = result.scalars().all() - - assert len(attempts) == 1 - assert attempts[0].trigger == DeliveryTrigger.MANUAL_REPLAY - assert attempts[0].result == DeliveryAttemptResult.SUCCEEDED - - await db.refresh(delivery) - - assert delivery.status == DeliveryStatus.SUCCEEDED - -@pytest.mark.asyncio -async def test_form_belongs_to_workspace(db: AsyncSession) -> None: - workspace = Workspace(name="Test Workspace") - form = Form( - title="Contact Form", - workspace=workspace, + destination = Destination( + form=foreign_form, + type="telegram", + reference="123456", ) + db.add(destination) + await db.flush() - db.add(form) - await db.commit() - - assert form.workspace_id == workspace.id - assert form.workspace is workspace - - -@pytest.mark.asyncio -async def test_workspace_contains_forms(db: AsyncSession) -> None: - workspace = Workspace(name="Agency Workspace") - - form_a = Form( - title="Contact Form", - workspace=workspace, - ) - form_b = Form( - title="Quote Form", - workspace=workspace, + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, ) + db.add(delivery) - db.add_all([form_a, form_b]) await db.commit() - await db.refresh(workspace, ["forms"]) + response = await client.post(f"/api/deliveries/{delivery.id}/replay") - assert len(workspace.forms) == 2 - assert {form.title for form in workspace.forms} == { - "Contact Form", - "Quote Form", - } + assert response.status_code == 404 -@pytest.mark.asyncio -async def test_form_workspace_relationship_persists_after_reload( +async def test_replay_requires_authentication( db: AsyncSession, -) -> None: - workspace = Workspace(name="Persistent Workspace") - form = Form( - title="Demo Request", + client: AsyncClient, +): + workspace = await create_workspace(db) + + form = await create_form( + db, workspace=workspace, ) - db.add(form) - await db.commit() + await db.flush() - workspace_id = workspace.id - form_id = form.id + submission = await create_submission(db, form=form) - db.expunge_all() - - result = await db.execute(select(Form).where(Form.id == form_id)) - loaded_form = result.scalar_one() - - assert loaded_form.workspace_id == workspace_id - - await db.refresh(loaded_form, ["workspace"]) - - assert loaded_form.workspace.id == workspace_id - assert loaded_form.workspace.name == "Persistent Workspace" - - -@pytest.mark.asyncio -async def test_deleting_workspace_deletes_its_forms( - db: AsyncSession, -) -> None: - workspace = Workspace(name="Disposable Workspace") - form = Form( - title="Disposable Form", - workspace=workspace, + destination = Destination( + form=form, + type="telegram", + reference="123456", ) + db.add(destination) + await db.flush() - db.add(form) - await db.commit() - - form_id = form.id + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + ) + db.add(delivery) - await db.delete(workspace) await db.commit() - result = await db.execute(select(Form).where(Form.id == form_id)) + client.cookies.clear() - assert result.scalar_one_or_none() is None - - -@pytest.mark.asyncio -async def test_form_cannot_exist_without_workspace( - db: AsyncSession, -) -> None: - form = Form(title="Orphan Form") - - db.add(form) - - with pytest.raises(Exception): - await db.commit() + response = await client.post(f"/api/deliveries/{delivery.id}/replay") - await db.rollback() + assert response.status_code == 401 diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..c8427c9 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,1113 @@ +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Form, Submission, Workspace, WorkspaceRole +from app.database.queries.auth import get_user_by_email +from app.database.queries.forms import get_accessible_form_by_id +from app.services.workspace import list_user_workspaces +from tests.conftest import ( + create_authenticated_workspace, + create_form, + create_membership, + create_user, + create_workspace, + register_and_get_user, +) + + +@pytest.mark.asyncio +async def test_invalid_form_uuid(client: AsyncClient): + random_id = uuid.uuid4() + + response = await client.post( + f"/f/{random_id}", + json={"dummy": "data"}, + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_form_submissions_returns_only_its_submissions( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) + + submission_1 = Submission( + form=form, + payload={"name": "Ivan"}, + ) + submission_2 = Submission( + form=form, + payload={"name": "Petro"}, + ) + + db.add(form) + await db.commit() + + response = await client.get(f"/api/forms/{form.id}/submissions") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 2 + assert {item["id"] for item in data} == { + submission_1.id, + submission_2.id, + } + + +@pytest.mark.asyncio +async def test_get_form_submissions_does_not_return_other_form_submissions( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form_1 = await create_form(db, title="Form 1", workspace=workspace) + form_2 = await create_form(db, title="Form 2", workspace=workspace) + + submission_1 = Submission( + form=form_1, + payload={"name": "Ivan"}, + ) + submission_2 = Submission( + form=form_2, + payload={"name": "Petro"}, + ) + + db.add_all([form_1, form_2]) + await db.commit() + + response = await client.get(f"/api/forms/{form_1.id}/submissions") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 1 + assert data[0]["id"] == submission_1.id + assert data[0]["id"] != submission_2.id + + +@pytest.mark.asyncio +async def test_get_form_submissions_are_ordered_newest_first( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) + + old_submission = Submission( + form=form, + payload={"name": "Old"}, + created_at=datetime.now(timezone.utc) - timedelta(hours=2), + ) + new_submission = Submission( + form=form, + payload={"name": "New"}, + created_at=datetime.now(timezone.utc), + ) + + db.add(form) + await db.commit() + + response = await client.get(f"/api/forms/{form.id}/submissions") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 2 + assert data[0]["id"] == new_submission.id + assert data[1]["id"] == old_submission.id + + +@pytest.mark.asyncio +async def test_get_form_submissions_respects_limit( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) + + for i in range(5): + Submission( + form=form, + payload={"number": i}, + ) + + db.add(form) + await db.commit() + + response = await client.get( + f"/api/forms/{form.id}/submissions", + params={"limit": 2}, + ) + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 2 + + +@pytest.mark.asyncio +async def test_get_form_submissions_respects_offset( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) + + now = datetime.now(timezone.utc) + + submissions = [ + Submission( + form=form, + payload={"number": i}, + created_at=now + timedelta(seconds=i), + ) + for i in range(5) + ] + + db.add(form) + await db.commit() + + response = await client.get( + f"/api/forms/{form.id}/submissions", + params={ + "limit": 2, + "offset": 2, + }, + ) + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 2 + assert data[0]["id"] == submissions[2].id + assert data[1]["id"] == submissions[1].id + + +@pytest.mark.asyncio +async def test_get_form_submissions_returns_404_for_unknown_form( + client: AsyncClient, db: AsyncSession +): + await create_authenticated_workspace( + client, + db, + ) + form_id = uuid.uuid4() + + response = await client.get(f"/api/forms/{form_id}/submissions") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_form_belongs_to_workspace(db: AsyncSession) -> None: + form = await create_form(db, title="Contact Form") + await db.commit() + + assert form.workspace_id == form.workspace.id + + +@pytest.mark.asyncio +async def test_workspace_contains_forms(db: AsyncSession) -> None: + workspace = Workspace(name="Agency Workspace") + first = Form(title="Contact Form", workspace=workspace) + second = Form(title="Quote Form", workspace=workspace) + db.add_all([first, second]) + await db.commit() + + await db.refresh(workspace, ["forms"]) + assert len(workspace.forms) == 2 + assert {form.title for form in workspace.forms} == {"Contact Form", "Quote Form"} + + +@pytest.mark.asyncio +async def test_form_cannot_exist_without_workspace(db: AsyncSession) -> None: + from app.database.models import Form + + db.add(Form(title="Orphan Form")) + with pytest.raises(Exception): + await db.commit() + await db.rollback() + + +async def test_list_forms_requires_authentication(client): + client.cookies.clear() + + response = await client.get("/api/forms") + + assert response.status_code == 401 + + +async def test_list_user_workspaces_returns_accessible_workspaces( + db: AsyncSession, +): + user = await create_user(db) + + first = await create_workspace(db, name="First") + second = await create_workspace(db, name="Second") + + await create_membership( + db, + user=user, + workspace=first, + role=WorkspaceRole.OWNER, + ) + await create_membership( + db, + user=user, + workspace=second, + role=WorkspaceRole.MEMBER, + ) + + result = await list_user_workspaces(db, user) + + assert {workspace.id for workspace in result} == { + first.id, + second.id, + } + + +async def test_list_user_workspaces_does_not_return_foreign_workspace( + db: AsyncSession, +): + user = await create_user( + db, + email="user@example.com", + ) + other_user = await create_user( + db, + email="other@example.com", + ) + + own_workspace = await create_workspace(db, name="Own") + foreign_workspace = await create_workspace(db, name="Foreign") + + await create_membership( + db, + user=user, + workspace=own_workspace, + ) + await create_membership( + db, + user=other_user, + workspace=foreign_workspace, + role=WorkspaceRole.OWNER, + ) + + result = await list_user_workspaces(db, user) + + assert [workspace.id for workspace in result] == [ + own_workspace.id, + ] + + +async def test_list_user_workspaces_returns_empty_list_without_memberships( + db: AsyncSession, +): + user = await create_user(db) + + result = await list_user_workspaces(db, user) + + assert result == [] + + +async def test_list_workspaces_requires_authentication(client): + client.cookies.clear() + + response = await client.get("/api/workspaces") + + assert response.status_code == 401 + + +async def test_list_workspaces_returns_current_user_workspaces( + client, + db: AsyncSession, +): + register_response = await client.post( + "/api/auth/register", + json={ + "email": "user@example.com", + "password": "strong-password", + }, + ) + assert register_response.status_code == 201 + + user = await get_user_by_email( + db, + "user@example.com", + ) + assert user is not None + + first = await create_workspace(db, name="First") + second = await create_workspace(db, name="Second") + + await create_membership( + db, + user=user, + workspace=first, + role=WorkspaceRole.OWNER, + ) + await create_membership( + db, + user=user, + workspace=second, + role=WorkspaceRole.MEMBER, + ) + + # HTTP request uses another AsyncSession. + await db.commit() + + response = await client.get("/api/workspaces") + + assert response.status_code == 200 + + data = response.json() + + assert {workspace["id"] for workspace in data} == { + str(first.id), + str(second.id), + } + assert {workspace["name"] for workspace in data} == { + "First", + "Second", + } + + +async def test_list_workspaces_does_not_expose_other_users_workspace( + client, + db: AsyncSession, +): + register_response = await client.post( + "/api/auth/register", + json={ + "email": "user@example.com", + "password": "strong-password", + }, + ) + assert register_response.status_code == 201 + + user = await get_user_by_email( + db, + "user@example.com", + ) + assert user is not None + + other_user = await create_user( + db, + email="other@example.com", + ) + + own_workspace = await create_workspace( + db, + name="Visible", + ) + foreign_workspace = await create_workspace( + db, + name="Secret", + ) + + await create_membership( + db, + user=user, + workspace=own_workspace, + ) + await create_membership( + db, + user=other_user, + workspace=foreign_workspace, + role=WorkspaceRole.OWNER, + ) + + await db.commit() + + response = await client.get("/api/workspaces") + + assert response.status_code == 200 + + data = response.json() + ids = {workspace["id"] for workspace in data} + + assert str(own_workspace.id) in ids + assert str(foreign_workspace.id) not in ids + + +async def test_get_forms_returns_forms( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + form_1 = await create_form( + db, + title="Form 1", + workspace=workspace, + ) + form_2 = await create_form( + db, + title="Form 2", + workspace=workspace, + ) + + db.add_all([form_1, form_2]) + await db.commit() + + response = await client.get("/api/forms") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 2 + assert {item["id"] for item in data} == { + str(form_1.id), + str(form_2.id), + } + + +async def test_get_forms_are_ordered_newest_first( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + now = datetime.now(timezone.utc) + + old_form = await create_form( + db, + title="Old form", + workspace=workspace, + created_at=now - timedelta(hours=1), + ) + new_form = await create_form( + db, + title="New form", + workspace=workspace, + created_at=now, + ) + + db.add_all([old_form, new_form]) + await db.commit() + + response = await client.get("/api/forms") + + assert response.status_code == 200 + + data = response.json() + + assert [item["id"] for item in data] == [ + str(new_form.id), + str(old_form.id), + ] + + +async def test_get_forms_respects_limit_and_offset( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + now = datetime.now(timezone.utc) + + forms = [ + await create_form( + db, + title=f"Form {i}", + workspace=workspace, + created_at=now + timedelta(seconds=i), + ) + for i in range(5) + ] + + db.add_all(forms) + await db.commit() + + response = await client.get( + "/api/forms", + params={ + "limit": 2, + "offset": 1, + }, + ) + + assert response.status_code == 200 + + data = response.json() + + assert [item["id"] for item in data] == [ + str(forms[3].id), + str(forms[2].id), + ] + + +async def test_get_forms_returns_empty_list_when_no_forms_exist( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + await db.commit() + + response = await client.get("/api/forms") + + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_forms_returns_only_forms_from_accessible_workspaces( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + other_user = await create_user( + db, + email="other@example.com", + ) + + own_workspace = await create_workspace( + db, + name="Own Workspace", + ) + foreign_workspace = await create_workspace( + db, + name="Foreign Workspace", + ) + + await create_membership( + db, + user=user, + workspace=own_workspace, + role=WorkspaceRole.OWNER, + ) + await create_membership( + db, + user=other_user, + workspace=foreign_workspace, + role=WorkspaceRole.OWNER, + ) + + own_form = await create_form( + db, + title="Visible", + workspace=own_workspace, + ) + foreign_form = await create_form( + db, + title="Secret", + workspace=foreign_workspace, + ) + + db.add_all([own_form, foreign_form]) + await db.commit() + + response = await client.get("/api/forms") + + assert response.status_code == 200 + + ids = {item["id"] for item in response.json()} + + assert str(own_form.id) in ids + assert str(foreign_form.id) not in ids + + +async def test_list_forms_member_can_access_workspace_forms( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + + db.add(form) + await db.commit() + + response = await client.get("/api/forms") + + assert response.status_code == 200 + + ids = {item["id"] for item in response.json()} + assert str(form.id) in ids + + +async def test_list_forms_returns_forms_from_multiple_accessible_workspaces( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + first_workspace = await create_workspace(db, name="First") + second_workspace = await create_workspace(db, name="Second") + + await create_membership( + db, + user=user, + workspace=first_workspace, + role=WorkspaceRole.OWNER, + ) + await create_membership( + db, + user=user, + workspace=second_workspace, + role=WorkspaceRole.MEMBER, + ) + + first_form = await create_form( + db, + title="First Form", + workspace=first_workspace, + ) + second_form = await create_form( + db, + title="Second Form", + workspace=second_workspace, + ) + + db.add_all([first_form, second_form]) + await db.commit() + + response = await client.get("/api/forms") + + assert response.status_code == 200 + + ids = {item["id"] for item in response.json()} + + assert ids == { + str(first_form.id), + str(second_form.id), + } + + +async def test_get_accessible_form_by_id_returns_form_for_owner( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + result = await get_accessible_form_by_id( + db, + form.id, + user.id, + ) + + assert result is not None + assert result.id == form.id + + +async def test_get_accessible_form_by_id_returns_form_for_member( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + result = await get_accessible_form_by_id( + db, + form.id, + user.id, + ) + + assert result is not None + assert result.id == form.id + + +async def test_get_accessible_form_by_id_returns_none_for_foreign_workspace( + db: AsyncSession, +): + user = await create_user(db) + other_user = await create_user( + db, + email="other@example.com", + ) + + own_workspace = await create_workspace( + db, + name="Own", + ) + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + await create_membership( + db, + user=user, + workspace=own_workspace, + role=WorkspaceRole.OWNER, + ) + await create_membership( + db, + user=other_user, + workspace=foreign_workspace, + role=WorkspaceRole.OWNER, + ) + + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() + + result = await get_accessible_form_by_id( + db, + foreign_form.id, + user.id, + ) + + assert result is None + + +async def test_get_accessible_form_by_id_returns_none_for_unknown_form( + db: AsyncSession, +): + user = await create_user(db) + + result = await get_accessible_form_by_id( + db, + uuid.uuid4(), + user.id, + ) + + assert result is None + + +async def test_get_form_submissions_returns_submissions_for_member( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + submission = Submission( + form=form, + payload={"name": "Alice"}, + ) + db.add(submission) + + # HTTP request has another DB session. + await db.commit() + + response = await client.get(f"/api/forms/{form.id}/submissions") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 1 + assert data[0]["id"] == submission.id + assert data[0]["payload"] == {"name": "Alice"} + + +async def test_get_form_submissions_rejects_membership_in_different_workspace( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + allowed_workspace = await create_workspace( + db, + name="Allowed", + ) + forbidden_workspace = await create_workspace( + db, + name="Forbidden", + ) + + await create_membership( + db, + user=user, + workspace=allowed_workspace, + ) + + forbidden_form = await create_form( + db, + workspace=forbidden_workspace, + ) + db.add(forbidden_form) + + await db.commit() + + response = await client.get(f"/api/forms/{forbidden_form.id}/submissions") + + assert response.status_code == 404 + + +async def test_get_form_submissions_requires_authentication( + db: AsyncSession, + client: AsyncClient, +): + workspace = await create_workspace(db) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.commit() + + client.cookies.clear() + + response = await client.get(f"/api/forms/{form.id}/submissions") + + assert response.status_code == 401 + + +async def test_create_form_requires_authentication( + db: AsyncSession, + client: AsyncClient, +): + workspace = await create_workspace(db) + await db.commit() + + client.cookies.clear() + + response = await client.post( + "/api/forms", + json={ + "workspace_id": str(workspace.id), + "title": "Contact", + "language": "en", + "destinations": [], + }, + ) + + assert response.status_code == 401 + + +async def test_create_form_in_accessible_workspace( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + await db.commit() + + response = await client.post( + "/api/forms", + json={ + "workspace_id": str(workspace.id), + "title": "Contact", + "language": "en", + "destinations": [], + }, + ) + + assert response.status_code == 201 + + form_id = uuid.UUID(response.json()["id"]) + + form = await db.get(Form, form_id) + + assert form is not None + assert form.workspace_id == workspace.id + + +async def test_create_form_rejects_foreign_workspace( + db: AsyncSession, + client: AsyncClient, +): + await register_and_get_user(client, db) + + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + await db.commit() + + response = await client.post( + "/api/forms", + json={ + "workspace_id": str(foreign_workspace.id), + "title": "Injected Form", + "language": "en", + "destinations": [], + }, + ) + + assert response.status_code == 404 + + +async def test_rejected_foreign_form_creation_does_not_create_form( + db: AsyncSession, + client: AsyncClient, +): + await register_and_get_user(client, db) + + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + await db.commit() + + response = await client.post( + "/api/forms", + json={ + "workspace_id": str(foreign_workspace.id), + "title": "Should Not Exist", + "language": "en", + "destinations": [], + }, + ) + + assert response.status_code == 404 + + form = await db.scalar(select(Form).where(Form.title == "Should Not Exist")) + + assert form is None + + +async def test_member_can_create_form( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + await db.commit() + + response = await client.post( + "/api/forms", + json={ + "workspace_id": str(workspace.id), + "title": "Member Form", + "language": "en", + "destinations": [], + }, + ) + + assert response.status_code == 201 diff --git a/tests/test_replay.py b/tests/test_replay.py new file mode 100644 index 0000000..016512a --- /dev/null +++ b/tests/test_replay.py @@ -0,0 +1,284 @@ +from datetime import datetime, timezone + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import ( + DeliveryAttempt, + DeliveryAttemptResult, + DeliveryStatus, + DeliveryTrigger, + WorkspaceRole, +) +from app.services.delivery import ( + DeliveryNotFoundError, + DeliveryNotReplayableError, + claim_next_delivery, + process_delivery, + queue_manual_replay, +) +from tests.conftest import ( + create_authenticated_workspace, + create_delivery, + create_membership, + create_user, + create_workspace, +) + + +@pytest.mark.asyncio +async def test_failed_delivery_can_be_queued_for_manual_replay( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + delivery = await create_delivery( + db, + DeliveryStatus.FAILED, + workspace=workspace, + ) + + delivery.attempt_count = 5 + delivery.next_retry_at = datetime.now(timezone.utc) + + await db.flush() + + result = await queue_manual_replay( + db, + delivery.id, + user.id, + ) + + assert result.status == DeliveryStatus.PENDING + assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY + assert result.failure_type is None + + # Replay starts a new processing cycle, + # but does not rewrite attempt history. + assert result.attempt_count == 5 + assert result.next_retry_at is None + assert result.processing_started_at is None + + +@pytest.mark.asyncio +async def test_unknown_delivery_can_be_queued_for_manual_replay( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + delivery = await create_delivery( + db, + DeliveryStatus.UNKNOWN, + workspace=workspace, + ) + + result = await queue_manual_replay( + db, + delivery.id, + user.id, + ) + + assert result.status == DeliveryStatus.PENDING + assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY + assert result.failure_type is None + assert result.next_retry_at is None + assert result.processing_started_at is None + + +@pytest.mark.asyncio +async def test_manual_replay_raises_not_found_for_missing_delivery( + db: AsyncSession, +): + user = await create_user(db) + + with pytest.raises(DeliveryNotFoundError): + await queue_manual_replay( + db, + 999999, + user.id, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status", + [ + DeliveryStatus.PENDING, + DeliveryStatus.PROCESSING, + DeliveryStatus.AWAITING_RETRY, + DeliveryStatus.SUCCEEDED, + ], +) +async def test_manual_replay_rejects_non_replayable_status( + db: AsyncSession, + status: DeliveryStatus, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + delivery = await create_delivery( + db, + status, + workspace=workspace, + ) + + with pytest.raises(DeliveryNotReplayableError): + await queue_manual_replay( + db, + delivery.id, + user.id, + ) + + +@pytest.mark.asyncio +async def test_replay_failed_delivery_endpoint(client, db: AsyncSession): + user, workspace = await create_authenticated_workspace(client, db) + + delivery = await create_delivery( + db, + DeliveryStatus.FAILED, + workspace=workspace, + ) + + response = await client.post(f"/api/deliveries/{delivery.id}/replay") + + assert response.status_code == 202 + assert response.json()["status"] == DeliveryStatus.PENDING.value + + +@pytest.mark.asyncio +async def test_replay_missing_delivery_returns_404(client, db: AsyncSession): + await create_authenticated_workspace(client, db) + + response = await client.post("/api/deliveries/999999/replay") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_manual_replay_is_recorded_in_attempt_history( + client, + db: AsyncSession, + monkeypatch, +): + user, workspace = await create_authenticated_workspace(client, db) + + delivery = await create_delivery( + db, + DeliveryStatus.FAILED, + workspace=workspace, + ) + + response = await client.post(f"/api/deliveries/{delivery.id}/replay") + assert response.status_code == 202 + + db.expire_all() + claimed = await claim_next_delivery(db) + assert claimed.queued_trigger == DeliveryTrigger.MANUAL_REPLAY + + async def fake_execute_delivery_attempt(destination, message): + return {"success": True, "external_reference": "manual-replay-test"} + + monkeypatch.setattr( + "app.services.delivery.execute_delivery_attempt", + fake_execute_delivery_attempt, + ) + + await process_delivery(delivery.id, db) + + attempt = await db.scalar( + select(DeliveryAttempt).where(DeliveryAttempt.delivery_id == delivery.id) + ) + + assert attempt.trigger == DeliveryTrigger.MANUAL_REPLAY + assert attempt.result == DeliveryAttemptResult.SUCCEEDED + + +@pytest.mark.asyncio +async def test_manual_replay_rejects_delivery_from_inaccessible_workspace( + db: AsyncSession, +): + user = await create_user(db) + + foreign_workspace = await create_workspace( + db, + name="Foreign Workspace", + ) + + delivery = await create_delivery( + db, + DeliveryStatus.FAILED, + workspace=foreign_workspace, + ) + + with pytest.raises(DeliveryNotFoundError): + await queue_manual_replay( + db, + delivery.id, + user.id, + ) + + await db.refresh(delivery) + + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type is not None + + +@pytest.mark.asyncio +async def test_manual_replay_membership_in_other_workspace_does_not_grant_access( + db: AsyncSession, +): + user = await create_user(db) + + accessible_workspace = await create_workspace( + db, + name="Accessible", + ) + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + await create_membership( + db, + user=user, + workspace=accessible_workspace, + role=WorkspaceRole.OWNER, + ) + + delivery = await create_delivery( + db, + DeliveryStatus.FAILED, + workspace=foreign_workspace, + ) + + with pytest.raises(DeliveryNotFoundError): + await queue_manual_replay( + db, + delivery.id, + user.id, + ) diff --git a/tests/test_submissions.py b/tests/test_submissions.py new file mode 100644 index 0000000..35584a6 --- /dev/null +++ b/tests/test_submissions.py @@ -0,0 +1,439 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DeliveryStatus, WorkspaceRole +from app.database.queries.submissions import get_accessible_submission_by_id +from tests.conftest import ( + create_authenticated_workspace, + create_delivery, + create_form, + create_membership, + create_submission, + create_user, + create_workspace, + register_and_get_user, +) + + +@pytest.mark.asyncio +async def test_get_submission_returns_submission_with_payload( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + + await db.commit() + + delivery = await create_delivery( + db, + DeliveryStatus.PENDING, + workspace=workspace, + ) + + submission = delivery.submission + + response = await client.get(f"/api/submissions/{submission.id}") + + assert response.status_code == 200 + + data = response.json() + + assert data["id"] == submission.id + assert data["payload"] == {"email": "test@example.com"} + assert "created_at" in data + + +@pytest.mark.asyncio +async def test_get_submission_returns_deliveries( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + + await db.commit() + + delivery = await create_delivery( + db, + DeliveryStatus.PENDING, + workspace=workspace, + ) + + submission = delivery.submission + + response = await client.get(f"/api/submissions/{submission.id}") + + assert response.status_code == 200 + + data = response.json() + + assert len(data["deliveries"]) == 1 + + stored_delivery = data["deliveries"][0] + + assert stored_delivery["id"] == delivery.id + assert stored_delivery["destination_id"] == delivery.destination_id + assert stored_delivery["status"] == DeliveryStatus.PENDING.value + assert stored_delivery["attempt_count"] == 0 + + +@pytest.mark.asyncio +async def test_get_submission_returns_delivery_state( + db: AsyncSession, + client: AsyncClient, +): + _, workspace = await create_authenticated_workspace( + client, + db, + ) + + await db.commit() + + delivery = await create_delivery( + db, + DeliveryStatus.AWAITING_RETRY, + workspace=workspace, + ) + + delivery.attempt_count = 2 + delivery.last_error = "Telegram unavailable" + delivery.next_retry_at = datetime.now(timezone.utc) + timedelta(minutes=1) + + await db.commit() + + response = await client.get(f"/api/submissions/{delivery.submission_id}") + + assert response.status_code == 200 + + data = response.json() + stored_delivery = data["deliveries"][0] + + assert stored_delivery["status"] == DeliveryStatus.AWAITING_RETRY.value + assert stored_delivery["attempt_count"] == 2 + assert stored_delivery["last_error"] == "Telegram unavailable" + assert stored_delivery["next_retry_at"] is not None + assert stored_delivery["delivered_at"] is None + + +@pytest.mark.asyncio +async def test_get_submission_not_found(client: AsyncClient, db: AsyncSession): + await create_authenticated_workspace( + client, + db, + ) + + await db.commit() + + response = await client.get("/api/submissions/999999") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_submission_invalid_id(client: AsyncClient, db: AsyncSession): + await create_authenticated_workspace( + client, + db, + ) + + await db.commit() + response = await client.get("/api/submissions/not-an-id") + + assert response.status_code == 422 + + +async def test_get_accessible_submission_by_id_returns_submission_for_member( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + submission = await create_submission( + db, + form=form, + payload={"name": "Alice"}, + ) + + result = await get_accessible_submission_by_id( + db, + submission.id, + user.id, + ) + + assert result is not None + assert result.id == submission.id + assert result.payload == {"name": "Alice"} + + +async def test_get_accessible_submission_by_id_returns_submission_for_owner( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + submission = await create_submission( + db, + form=form, + ) + + result = await get_accessible_submission_by_id( + db, + submission.id, + user.id, + ) + + assert result is not None + assert result.id == submission.id + + +async def test_get_accessible_submission_by_id_returns_none_for_foreign_workspace( + db: AsyncSession, +): + user = await create_user(db) + + other_user = await create_user( + db, + email="other@example.com", + ) + + own_workspace = await create_workspace( + db, + name="Own", + ) + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + await create_membership( + db, + user=user, + workspace=own_workspace, + ) + await create_membership( + db, + user=other_user, + workspace=foreign_workspace, + ) + + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() + + foreign_submission = await create_submission( + db, + form=foreign_form, + payload={"secret": "hidden"}, + ) + + result = await get_accessible_submission_by_id( + db, + foreign_submission.id, + user.id, + ) + + assert result is None + + +async def test_get_accessible_submission_by_id_returns_none_for_unknown_submission( + db: AsyncSession, +): + user = await create_user(db) + + result = await get_accessible_submission_by_id( + db, + 999999999, + user.id, + ) + + assert result is None + + +async def test_get_submission_returns_accessible_submission( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + submission = await create_submission( + db, + form=form, + payload={ + "name": "Alice", + "email": "alice@example.com", + }, + ) + + await db.commit() + + response = await client.get(f"/api/submissions/{submission.id}") + + assert response.status_code == 200 + + data = response.json() + + assert data["id"] == submission.id + assert data["payload"] == { + "name": "Alice", + "email": "alice@example.com", + } + + +async def test_get_submission_hides_foreign_workspace_submission( + db: AsyncSession, + client: AsyncClient, +): + user = await register_and_get_user(client, db) + + other_user = await create_user( + db, + email="other@example.com", + ) + + own_workspace = await create_workspace( + db, + name="Own", + ) + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + await create_membership( + db, + user=user, + workspace=own_workspace, + ) + await create_membership( + db, + user=other_user, + workspace=foreign_workspace, + ) + + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() + + foreign_submission = await create_submission( + db, + form=foreign_form, + payload={"secret": "must-not-leak"}, + ) + + await db.commit() + + response = await client.get(f"/api/submissions/{foreign_submission.id}") + + assert response.status_code == 404 + + +async def test_get_submission_returns_404_without_workspace_membership( + db: AsyncSession, + client: AsyncClient, +): + await register_and_get_user(client, db) + + workspace = await create_workspace(db) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + submission = await create_submission( + db, + form=form, + ) + + await db.commit() + + response = await client.get(f"/api/submissions/{submission.id}") + + assert response.status_code == 404 + + +async def test_get_submission_requires_authentication( + db: AsyncSession, + client: AsyncClient, +): + workspace = await create_workspace(db) + + form = await create_form( + db, + workspace=workspace, + ) + db.add(form) + await db.flush() + + submission = await create_submission( + db, + form=form, + ) + + await db.commit() + + client.cookies.clear() + + response = await client.get(f"/api/submissions/{submission.id}") + + assert response.status_code == 401 diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 0000000..fef43f0 --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,42 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DeliveryStatus, FailureType +from app.services.delivery import MAX_DELIVERY_ATTEMPTS +from app.workers.delivery import recovery_stale_deliveries +from tests.conftest import create_delivery + + +@pytest.mark.asyncio +async def test_old_processing_delivery_is_recovered(db: AsyncSession): + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + delivery.processing_started_at = datetime.now(timezone.utc) - timedelta(minutes=10) + + assert await recovery_stale_deliveries(db) == 1 + await db.refresh(delivery) + assert delivery.status == DeliveryStatus.AWAITING_RETRY + assert delivery.processing_started_at is None + + +@pytest.mark.asyncio +async def test_recent_processing_delivery_is_untouched(db: AsyncSession): + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + delivery.processing_started_at = datetime.now(timezone.utc) - timedelta(minutes=1) + + assert await recovery_stale_deliveries(db) == 0 + await db.refresh(delivery) + assert delivery.status == DeliveryStatus.PROCESSING + + +@pytest.mark.asyncio +async def test_stale_delivery_with_exhausted_attempts_fails(db: AsyncSession): + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + delivery.processing_started_at = datetime.now(timezone.utc) - timedelta(minutes=10) + delivery.attempt_count = MAX_DELIVERY_ATTEMPTS + + await recovery_stale_deliveries(db) + await db.refresh(delivery) + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py new file mode 100644 index 0000000..6c232da --- /dev/null +++ b/tests/test_workspaces.py @@ -0,0 +1,403 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User, Workspace, WorkspaceMembership, WorkspaceRole +from app.database.queries.auth import get_user_by_email +from app.database.queries.workspace import ( + get_workspace_membership, + require_workspace_role, +) +from app.exceptions import ( + WorkspaceAccessDeniedError, + WorkspaceNotFoundError, + WorkspacePermissionDeniedError, +) +from app.services.workspace import ( + get_user_workspace, + list_user_workspaces, +) +from tests.conftest import create_membership, create_user, create_workspace + + +async def test_get_user_workspace_returns_workspace_for_owner( + db: AsyncSession, +): + user = User(email="owner@example.com") + workspace = Workspace(name="Test Workspace") + + membership = WorkspaceMembership( + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + db.add(membership) + await db.flush() + + result = await get_user_workspace( + db, + user, + workspace.id, + ) + + assert result.id == workspace.id + + +async def test_get_user_workspace_returns_workspace_for_member( + db: AsyncSession, +): + user = User(email="member@example.com") + workspace = Workspace(name="Test Workspace") + + membership = WorkspaceMembership( + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + db.add(membership) + await db.flush() + + result = await get_user_workspace( + db, + user, + workspace.id, + ) + + assert result.id == workspace.id + + +async def test_get_user_workspace_rejects_user_without_membership( + db: AsyncSession, +): + user = User(email="outsider@example.com") + workspace = Workspace(name="Private Workspace") + + db.add_all([user, workspace]) + await db.flush() + + with pytest.raises(WorkspaceAccessDeniedError): + await get_user_workspace( + db, + user, + workspace.id, + ) + + +async def test_get_user_workspace_rejects_membership_in_different_workspace( + db: AsyncSession, +): + user = User(email="member@example.com") + allowed_workspace = Workspace(name="Allowed") + forbidden_workspace = Workspace(name="Forbidden") + + membership = WorkspaceMembership( + user=user, + workspace=allowed_workspace, + role=WorkspaceRole.MEMBER, + ) + + db.add_all([membership, forbidden_workspace]) + await db.flush() + + with pytest.raises(WorkspaceAccessDeniedError): + await get_user_workspace( + db, + user, + forbidden_workspace.id, + ) + + +async def test_list_user_workspaces_returns_accessible_workspaces( + db: AsyncSession, +): + user = User(email="user@example.com") + first = Workspace(name="First") + second = Workspace(name="Second") + + db.add_all( + [ + WorkspaceMembership( + user=user, + workspace=first, + role=WorkspaceRole.OWNER, + ), + WorkspaceMembership( + user=user, + workspace=second, + role=WorkspaceRole.MEMBER, + ), + ] + ) + await db.commit() + + result = await list_user_workspaces(db, user) + + assert {workspace.id for workspace in result} == { + first.id, + second.id, + } + + +async def test_list_user_workspaces_does_not_return_foreign_workspace( + db: AsyncSession, +): + user = User(email="user@example.com") + other_user = User(email="other@example.com") + + own_workspace = Workspace(name="Own") + foreign_workspace = Workspace(name="Foreign") + + db.add_all( + [ + WorkspaceMembership( + user=user, + workspace=own_workspace, + role=WorkspaceRole.MEMBER, + ), + WorkspaceMembership( + user=other_user, + workspace=foreign_workspace, + role=WorkspaceRole.OWNER, + ), + ] + ) + await db.commit() + + result = await list_user_workspaces(db, user) + + assert [workspace.id for workspace in result] == [own_workspace.id] + + +async def test_list_user_workspaces_returns_empty_list_without_memberships( + db: AsyncSession, +): + user = User(email="user@example.com") + db.add(user) + await db.flush() + + result = await list_user_workspaces(db, user) + + assert result == [] + + +async def test_list_workspaces_requires_authentication(client): + client.cookies.clear() + + response = await client.get("/api/workspaces") + + assert response.status_code == 401 + + +async def test_list_workspaces_returns_current_user_workspaces( + client, + db: AsyncSession, +): + await client.post( + "/api/auth/register", + json={ + "email": "user@example.com", + "password": "strong-password", + }, + ) + + user = await get_user_by_email(db, "user@example.com") + + first = Workspace(name="First") + second = Workspace(name="Second") + + db.add_all( + [ + WorkspaceMembership( + user=user, + workspace=first, + role=WorkspaceRole.OWNER, + ), + WorkspaceMembership( + user=user, + workspace=second, + role=WorkspaceRole.MEMBER, + ), + ] + ) + await db.commit() + + response = await client.get("/api/workspaces") + + assert response.status_code == 200 + + data = response.json() + + assert {workspace["name"] for workspace in data} == { + "First", + "Second", + } + + +async def test_list_workspaces_does_not_expose_other_users_workspace( + client, + db: AsyncSession, +): + await client.post( + "/api/auth/register", + json={ + "email": "user@example.com", + "password": "strong-password", + }, + ) + + user = await get_user_by_email(db, "user@example.com") + + other_user = User(email="other@example.com") + own_workspace = Workspace(name="Visible") + foreign_workspace = Workspace(name="Secret") + + db.add_all( + [ + WorkspaceMembership( + user=user, + workspace=own_workspace, + role=WorkspaceRole.MEMBER, + ), + WorkspaceMembership( + user=other_user, + workspace=foreign_workspace, + role=WorkspaceRole.OWNER, + ), + ] + ) + await db.commit() + + response = await client.get("/api/workspaces") + + assert response.status_code == 200 + + names = {workspace["name"] for workspace in response.json()} + + assert "Visible" in names + assert "Secret" not in names + + +async def test_get_workspace_membership_returns_membership( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + membership = await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + result = await get_workspace_membership( + db, + user.id, + workspace.id, + ) + + assert result is not None + assert result.id == membership.id + assert result.role == WorkspaceRole.MEMBER + + +async def test_get_workspace_membership_returns_none_without_membership( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + result = await get_workspace_membership( + db, + user.id, + workspace.id, + ) + + assert result is None + + +async def test_require_workspace_role_allows_owner( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + membership = await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.OWNER, + ) + + result = await require_workspace_role( + db, + user.id, + workspace.id, + {WorkspaceRole.OWNER}, + ) + + assert result.id == membership.id + + +async def test_require_workspace_role_rejects_member_for_owner_only_operation( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + with pytest.raises(WorkspacePermissionDeniedError): + await require_workspace_role( + db, + user.id, + workspace.id, + {WorkspaceRole.OWNER}, + ) + + +async def test_require_workspace_role_allows_multiple_roles( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + membership = await create_membership( + db, + user=user, + workspace=workspace, + role=WorkspaceRole.MEMBER, + ) + + result = await require_workspace_role( + db, + user.id, + workspace.id, + { + WorkspaceRole.OWNER, + WorkspaceRole.MEMBER, + }, + ) + + assert result.id == membership.id + + +async def test_require_workspace_role_hides_inaccessible_workspace( + db: AsyncSession, +): + user = await create_user(db) + workspace = await create_workspace(db) + + with pytest.raises(WorkspaceNotFoundError): + await require_workspace_role( + db, + user.id, + workspace.id, + {WorkspaceRole.OWNER}, + ) diff --git a/uv.lock b/uv.lock index 0c31d4c..4d9fd4c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "ackvia" @@ -8,6 +12,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "httpx" }, @@ -16,6 +21,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "sqlalchemy" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -31,6 +37,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.19.1" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -39,6 +46,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "sqlalchemy", specifier = ">=2.0.52" }, { name = "starlette", specifier = ">=1.6.0" }, { name = "uvicorn", specifier = ">=0.52.4" }, ] @@ -105,6 +113,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/a6/2b21ce5ebe4d8938a247c9b0dbb7271566ae559b01795c83ea4bb2660ed7/anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99", size = 131908, upload-time = "2026-09-02T21:46:35.485Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" }, + { url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -154,6 +217,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -416,6 +564,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5"