From 811ad2a9a7392efc9617e83a5679594989a1a845 Mon Sep 17 00:00:00 2001 From: loppify Date: Tue, 22 Sep 2026 10:18:47 +0200 Subject: [PATCH 1/4] auth --- app/api/auth.py | 79 ++ app/api/deliveries.py | 14 +- app/api/forms.py | 22 +- app/api/submissions.py | 4 +- app/database/queries/auth.py | 26 + app/database/session.py | 8 +- app/main.py | 6 +- app/schemas/auth.py | 20 + app/services/auth.py | 110 +++ pyproject.toml | 2 + tests/conftest.py | 130 +++ tests/helpers.py | 0 tests/test_auth.py | 726 ++++++++++++++++ tests/test_deliveries.py | 671 ++++++++++++++ tests/test_delivery.py | 0 tests/test_flow.py | 1596 ---------------------------------- tests/test_form.py | 0 tests/test_forms.py | 327 +++++++ tests/test_replay.py | 104 +++ tests/test_submission.py | 0 tests/test_submissions.py | 96 ++ tests/test_worker.py | 42 + uv.lock | 157 ++++ 23 files changed, 2521 insertions(+), 1619 deletions(-) create mode 100644 app/api/auth.py create mode 100644 app/database/queries/auth.py create mode 100644 app/schemas/auth.py create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_deliveries.py create mode 100644 tests/test_delivery.py delete mode 100644 tests/test_flow.py create mode 100644 tests/test_form.py create mode 100644 tests/test_forms.py create mode 100644 tests/test_replay.py create mode 100644 tests/test_submission.py create mode 100644 tests/test_submissions.py create mode 100644 tests/test_worker.py diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..cf55808 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,79 @@ +from urllib import response + +from fastapi import APIRouter, Depends, HTTPException, Response, status, Cookie +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 register_user, UserAlreadyExistsError, create_session, login_user, \ + InvalidCredentialsError, get_user_by_session_token, logout_session + +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..050f072 100644 --- a/app/api/deliveries.py +++ b/app/api/deliveries.py @@ -1,4 +1,4 @@ -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 @@ -15,14 +15,14 @@ @router.get("/{delivery_id}", response_model=DeliveryDetailRead) async def get_detailed_delivery( - delivery_id: int, - db: AsyncSession = Depends(get_db), + delivery_id: int, + db: AsyncSession = Depends(get_db), ): res = await get_detailed_delivery_by_id(db, delivery_id) if res is None: raise HTTPException( - status_code=404, + status_code=status.HTTP_404_NOT_FOUND, detail="Delivery not found", ) @@ -30,13 +30,13 @@ 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)): try: delivery = await manual_delivery(delivery_id, db) 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..6648c71 100644 --- a/app/api/forms.py +++ b/app/api/forms.py @@ -20,9 +20,9 @@ @router.post("", response_model=FormRead, status_code=status.HTTP_201_CREATED) async def create_form_endpoint( - data: FormCreate, - locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)], - db: AsyncSession = Depends(get_db), + data: FormCreate, + locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)], + db: AsyncSession = Depends(get_db), ): current_lang, _ = locale lang = data.language if data.language in SUPPORTED_LANGUAGES else current_lang @@ -32,15 +32,15 @@ async def create_form_endpoint( @router.get("/{form_id}/submissions", response_model=list[SubmissionBase]) 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), + 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), ): form = await get_form_by_id(db, form_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 @@ -49,8 +49,8 @@ async def get_form_submissions( @router.get("", response_model=list[FormRead]) 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), + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), ): return await get_all_forms(db=db, limit=limit, offset=offset) diff --git a/app/api/submissions.py b/app/api/submissions.py index 8a63101..b4872b1 100644 --- a/app/api/submissions.py +++ b/app/api/submissions.py @@ -1,4 +1,4 @@ -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 @@ -19,7 +19,7 @@ async def get_detailed_submission( 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/database/queries/auth.py b/app/database/queries/auth.py new file mode 100644 index 0000000..db04a67 --- /dev/null +++ b/app/database/queries/auth.py @@ -0,0 +1,26 @@ +import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import User, Session + + +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/session.py b/app/database/session.py index 2fa9665..2c4f27e 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -12,4 +12,10 @@ 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/main.py b/app/main.py index 91870c8..8e904b3 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ 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.auth import router as auth_router from app.core.i18n import ( SUPPORTED_LANGUAGES, get_locale, @@ -32,11 +33,12 @@ 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.get("/", response_class=HTMLResponse) async def render_index( - request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] + request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] ): language, translations = locale @@ -63,7 +65,7 @@ async def render_index( @app.get("/success", response_class=HTMLResponse) async def render_success( - request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] + request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] ): language, translations = locale 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/services/auth.py b/app/services/auth.py index e69de29..a6203e9 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -0,0 +1,110 @@ +import hashlib +import secrets +from datetime import timedelta, datetime, UTC + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, InvalidHashError +from sqlalchemy import func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User, Session +from app.database.queries.auth import get_user_by_email, get_session_by_token_hash, delete_session_token_by_token_hash + +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 + + +class UserAlreadyExistsError(Exception): + pass + + +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 + + +class InvalidCredentialsError(Exception): + pass + + +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/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..bd8b886 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,130 @@ +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, + Workspace, +) +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", + created_at=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 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..b44de99 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,726 @@ +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 get_user_by_session_token, login_user, InvalidCredentialsError, logout_session +from app.services.auth import ( + UserAlreadyExistsError, + create_session, + generate_session_token, + hash_password, + hash_session_token, + 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.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() + + 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_deliveries.py b/tests/test_deliveries.py new file mode 100644 index 0000000..501808f --- /dev/null +++ b/tests/test_deliveries.py @@ -0,0 +1,671 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + Delivery, + DeliveryAttempt, + DeliveryAttemptResult, + DeliveryStatus, + DeliveryTrigger, + Destination, + FailureType, + Form, + Submission, +) +from app.services.delivery import ( + MAX_DELIVERY_ATTEMPTS, + claim_next_delivery, + finish_delivery_attempt, + process_delivery, +) +from app.workers.delivery import recovery_stale_deliveries +from tests.conftest import create_delivery, create_form + + +@pytest.mark.asyncio +async def test_delivery_creation(db: AsyncSession): + form = await create_form( + db, + title="Landing Test", + language="en", + ) + + destination = Destination( + form=form, + type="telegram", + reference="987654321", + ) + + submission = Submission( + form=form, + payload={"name": "Ivan"}, + ) + + Delivery( + submission=submission, + destination=destination, + ) + + db.add(form) + await db.commit() + + stored_form = await db.scalar(select(Form).order_by(Form.id)) + stored_destination = await db.scalar(select(Destination).order_by(Destination.id)) + stored_submission = await db.scalar(select(Submission).order_by(Submission.id)) + stored_delivery = await db.scalar(select(Delivery).order_by(Delivery.id)) + + assert stored_form is not None + assert stored_destination is not None + assert stored_submission is not None + assert stored_delivery is not None + + assert stored_form.title == "Landing Test" + assert stored_destination.type == "telegram" + assert stored_submission.payload == {"name": "Ivan"} + assert stored_delivery.status == DeliveryStatus.PENDING + + +@pytest.mark.asyncio +async def test_new_delivery_starts_pending_without_attempts( + db: AsyncSession, +): + form = await create_form( + db, + title="Landing Test", + language="en", + ) + + destination = Destination( + form=form, + type="telegram", + reference="987654321", + ) + + submission = Submission( + form=form, + payload={"name": "Ivan"}, + ) + + Delivery( + submission=submission, + destination=destination, + ) + + db.add(form) + await db.commit() + + result = await db.execute(select(Delivery).options(selectinload(Delivery.attempts))) + delivery = result.scalar_one() + + assert delivery.status == DeliveryStatus.PENDING + assert delivery.attempt_count == 0 + assert delivery.attempts == [] + + +@pytest.mark.asyncio +async def test_duplicate_delivery_failure(db: AsyncSession): + form = await create_form( + db, + title="Landing Test", + language="en", + ) + + destination = Destination( + form=form, + type="telegram", + reference="987654321", + ) + + submission = Submission( + form=form, + payload={"name": "Ivan"}, + ) + + Delivery( + submission=submission, + destination=destination, + ) + Delivery( + submission=submission, + destination=destination, + ) + + db.add(form) + + with pytest.raises(IntegrityError): + await db.commit() + + await db.rollback() + + forms = (await db.scalars(select(Form))).all() + submissions = (await db.scalars(select(Submission))).all() + deliveries = (await db.scalars(select(Delivery))).all() + + assert forms == [] + assert submissions == [] + assert deliveries == [] + + +@pytest.mark.asyncio +async def test_delivery_states_are_independent( + db: AsyncSession, +): + form = await create_form( + db, + title="Landing Test", + language="en", + ) + + destination_a = Destination( + form=form, + type="telegram", + reference="111111111", + ) + destination_b = Destination( + form=form, + type="telegram", + reference="222222222", + ) + destination_c = Destination( + form=form, + type="telegram", + reference="333333333", + ) + + submission = Submission( + form=form, + payload={"name": "Ivan"}, + ) + + delivery_a = Delivery( + submission=submission, + destination=destination_a, + ) + delivery_b = Delivery( + submission=submission, + destination=destination_b, + ) + delivery_c = Delivery( + submission=submission, + destination=destination_c, + ) + + db.add(form) + await db.commit() + + delivery_a.status = DeliveryStatus.SUCCEEDED + + delivery_b.status = DeliveryStatus.FAILED + delivery_b.failure_type = FailureType.PERMANENT + + delivery_c.status = DeliveryStatus.PENDING + + await db.commit() + + result = await db.execute( + select(Delivery).where(Delivery.submission_id == submission.id) + ) + deliveries = result.scalars().all() + + assert len(deliveries) == 3 + + statuses = {delivery.status for delivery in deliveries} + + assert statuses == { + DeliveryStatus.SUCCEEDED, + DeliveryStatus.FAILED, + DeliveryStatus.PENDING, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status", + [ + DeliveryStatus.PENDING, + DeliveryStatus.SUCCEEDED, + DeliveryStatus.FAILED, + DeliveryStatus.AWAITING_RETRY, + DeliveryStatus.UNKNOWN, + ], +) +async def test_delivery_status_is_persisted( + db: AsyncSession, + status: DeliveryStatus, +): + delivery = await create_delivery( + db, + status=status, + ) + + result = await db.execute(select(Delivery).where(Delivery.id == delivery.id)) + stored_delivery = result.scalar_one() + + assert stored_delivery.status == status + + +@pytest.mark.asyncio +async def test_awaiting_retry_delivery_can_be_selected_when_due( + db: AsyncSession, +): + delivery = await create_delivery( + db, + status=DeliveryStatus.AWAITING_RETRY, + ) + + delivery.next_retry_at = datetime.now(timezone.utc) - timedelta(seconds=1) + await db.commit() + + now = datetime.now(timezone.utc) + + result = await db.execute( + select(Delivery).where( + Delivery.status == DeliveryStatus.AWAITING_RETRY, + Delivery.next_retry_at <= now, + ) + ) + selected_delivery = result.scalar_one() + + assert selected_delivery.id == delivery.id + + +@pytest.mark.asyncio +async def test_awaiting_retry_delivery_is_not_selected_before_due( + db: AsyncSession, +): + delivery = await create_delivery( + db, + status=DeliveryStatus.AWAITING_RETRY, + ) + + delivery.next_retry_at = datetime.now(timezone.utc) + timedelta(hours=1) + await db.commit() + + now = datetime.now(timezone.utc) + + result = await db.execute( + select(Delivery).where( + Delivery.status == DeliveryStatus.AWAITING_RETRY, + Delivery.next_retry_at <= now, + ) + ) + selected_delivery = result.scalar_one_or_none() + + assert selected_delivery is None + + +@pytest.mark.asyncio +async def test_retryable_failure_schedules_retry( + db: AsyncSession, +): + delivery = await create_delivery( + db, + DeliveryStatus.PROCESSING, + ) + delivery.attempt_count = 1 + delivery.processing_started_at = datetime.now(timezone.utc) + + attempt = DeliveryAttempt(delivery=delivery, trigger=delivery.queued_trigger) + db.add(attempt) + await db.commit() + + before = datetime.now(timezone.utc) + + await finish_delivery_attempt( + db, + delivery, + attempt, + { + "success": False, + "failure_type": "retryable_failure", + "error": "Telegram temporarily unavailable", + }, + ) + + after = datetime.now(timezone.utc) + + assert delivery.status == DeliveryStatus.AWAITING_RETRY + assert delivery.failure_type is None + assert delivery.last_error == "Telegram temporarily unavailable" + assert attempt.result == DeliveryAttemptResult.RETRYABLE_FAILURE + assert attempt.error == "Telegram temporarily unavailable" + assert attempt.finished_at is not None + assert delivery.next_retry_at is not None + assert before + timedelta(seconds=24) <= delivery.next_retry_at + assert delivery.next_retry_at <= after + timedelta(seconds=36) + assert delivery.processing_started_at is None + + +@pytest.mark.asyncio +async def test_retry_delay_increases_with_attempt_count( + db: AsyncSession, +): + delivery = await create_delivery( + db, + DeliveryStatus.PROCESSING, + ) + delivery.attempt_count = 3 + + attempt = DeliveryAttempt(delivery=delivery, trigger=delivery.queued_trigger) + db.add(attempt) + await db.commit() + + datetime.now(timezone.utc) + + await finish_delivery_attempt( + db, + delivery, + attempt, + { + "success": False, + "failure_type": "retryable_failure", + "error": "Temporary failure", + }, + ) + + after = datetime.now(timezone.utc) + + assert delivery.status == DeliveryStatus.AWAITING_RETRY + assert delivery.next_retry_at is not None + + assert after + timedelta(seconds=96) <= delivery.next_retry_at + assert delivery.next_retry_at <= after + timedelta(seconds=144) + + +@pytest.mark.asyncio +async def test_retryable_failure_exhausts_retries( + db: AsyncSession, +): + delivery = await create_delivery( + db, + DeliveryStatus.PROCESSING, + ) + delivery.attempt_count = 5 + delivery.next_retry_at = datetime.now(timezone.utc) + + attempt = DeliveryAttempt(delivery=delivery, trigger=delivery.queued_trigger) + db.add(attempt) + await db.commit() + + await finish_delivery_attempt( + db, + delivery, + attempt, + { + "success": False, + "failure_type": "retryable_failure", + "error": "Still unavailable", + }, + ) + + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED + assert delivery.last_error == "Still unavailable" + assert delivery.processing_started_at is None + + assert attempt.result == DeliveryAttemptResult.RETRYABLE_FAILURE + assert attempt.error == "Still unavailable" + assert attempt.finished_at is not None + assert delivery.next_retry_at is None + + +@pytest.mark.asyncio +async def test_unexpected_exception_schedules_retry(db: AsyncSession, monkeypatch): + async def fake_execute_delivery_attempt(destination, message): + raise RuntimeError("Test Failure") + + monkeypatch.setattr( + "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt + ) + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + + with pytest.raises(RuntimeError): + await process_delivery(delivery.id, db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery.status == DeliveryStatus.AWAITING_RETRY + assert delivery.attempts[-1].result == DeliveryAttemptResult.UNKNOWN + assert delivery.attempts[-1].finished_at is not None + assert delivery.next_retry_at is not None + assert delivery.processing_started_at is None + + +@pytest.mark.asyncio +async def test_unexpected_exception_respects_max_attempts( + db: AsyncSession, monkeypatch +): + async def fake_execute_delivery_attempt(destination, message): + raise RuntimeError("Test Failure") + + monkeypatch.setattr( + "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt + ) + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + delivery.attempt_count = MAX_DELIVERY_ATTEMPTS - 1 + + with pytest.raises(RuntimeError): + await process_delivery(delivery.id, db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED + assert delivery.attempts[-1].result == DeliveryAttemptResult.UNKNOWN + assert delivery.next_retry_at is None and delivery.processing_started_at is None + + +@pytest.mark.asyncio +async def test_successful_processing_still_works(db: AsyncSession, monkeypatch): + async def fake_execute_delivery_attempt(destination, message): + return {"success": True, "external_reference": str(12334567)} + + monkeypatch.setattr( + "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt + ) + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + delivery.attempt_count = MAX_DELIVERY_ATTEMPTS - 1 + + await process_delivery(delivery.id, db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + assert delivery is not None + assert delivery.status == DeliveryStatus.SUCCEEDED + assert delivery.attempts[-1].result == DeliveryAttemptResult.SUCCEEDED + assert delivery.delivered_at is not None + assert delivery.processing_started_at is None + + +@pytest.mark.asyncio +async def test_permanent_failre_still_works(db: AsyncSession, monkeypatch): + async def fake_execute_delivery_attempt(destination, message): + return { + "success": False, + "error": "Chat not found", + "failure_type": "permanent_failure", + } + + monkeypatch.setattr( + "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt + ) + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + + await process_delivery(delivery.id, db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery is not None + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type == FailureType.PERMANENT + assert delivery.attempts[-1].result == DeliveryAttemptResult.PERMANENT_FAILURE + assert delivery.next_retry_at is None + + +@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) + + recovery_amount = await recovery_stale_deliveries(db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery is not None + assert delivery.status == DeliveryStatus.AWAITING_RETRY + assert delivery.next_retry_at is not None + assert delivery.processing_started_at is None + assert recovery_amount == 1 + + +@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) + + recovery_amount = await recovery_stale_deliveries(db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery is not None + assert delivery.status == DeliveryStatus.PROCESSING + assert recovery_amount == 0 + + +@pytest.mark.asyncio +async def test_non_processing_deliveries_are_untouched(db: AsyncSession): + deliveries = [ + await create_delivery(db, DeliveryStatus.SUCCEEDED), + await create_delivery(db, DeliveryStatus.FAILED), + await create_delivery(db, DeliveryStatus.AWAITING_RETRY), + ] + for delivery in deliveries: + delivery.processing_started_at = datetime.now(timezone.utc) - timedelta( + minutes=10 + ) + + expected_statuses = {delivery.id: delivery.status for delivery in deliveries} + recovery_amount = await recovery_stale_deliveries(db) + + deliveries = await db.scalars( + select(Delivery).options(selectinload(Delivery.attempts)) + ) + for delivery in deliveries.all(): + assert delivery is not None + assert delivery.status == expected_statuses[delivery.id] + + assert recovery_amount == 0 + + +@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) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery is not None + assert delivery.status == DeliveryStatus.FAILED + assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED + assert delivery.next_retry_at is None + assert delivery.processing_started_at is None + + +@pytest.mark.asyncio +async def test_recovered_delivery_can_be_claimed(db: AsyncSession): + delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + delivery.processing_started_at = datetime.now(timezone.utc) - timedelta(minutes=10) + + await recovery_stale_deliveries(db) + + delivery = await db.scalar( + select(Delivery) + .options(selectinload(Delivery.attempts)) + .where(Delivery.id == delivery.id) + ) + + assert delivery is not None + assert delivery.status == DeliveryStatus.AWAITING_RETRY + assert delivery.next_retry_at is not None + assert delivery.processing_started_at is None + + delivery = await claim_next_delivery(db) + + assert delivery is not None + assert delivery.status == DeliveryStatus.PROCESSING + assert delivery.processing_started_at is not None + + +@pytest.mark.asyncio +async def test_get_delivery_returns_delivery(db: AsyncSession, client): + delivery = await create_delivery(db) + response = await client.get(f"/api/deliveries/{delivery.id}") + + assert response.status_code == 200 + assert response.json()["id"] == delivery.id + assert response.json()["attempts"] == [] + + +@pytest.mark.asyncio +async def test_get_delivery_returns_attempts(db: AsyncSession, client): + 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}") + data = response.json() + 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_delivery_returns_404_when_not_found(client): + response = await client.get("/api/deliveries/999999") + assert response.status_code == 404 diff --git a/tests/test_delivery.py b/tests/test_delivery.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_flow.py b/tests/test_flow.py deleted file mode 100644 index 6c475bf..0000000 --- a/tests/test_flow.py +++ /dev/null @@ -1,1596 +0,0 @@ -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 sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import ( - AsyncSession, - async_sessionmaker, - create_async_engine, -) -from sqlalchemy.orm import selectinload - -from app.database.models import ( - Base, - Delivery, - DeliveryAttempt, - DeliveryAttemptResult, - DeliveryStatus, - DeliveryTrigger, - Destination, - FailureType, - Form, - Submission, - Workspace, -) -from app.database.session import get_db -from app.main import app -from app.services.delivery import ( - MAX_DELIVERY_ATTEMPTS, - DeliveryNotFoundError, - DeliveryNotReplayableError, - claim_next_delivery, - finish_delivery_attempt, - manual_delivery, - process_delivery, -) -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, -) - - -# --------------------------------------------------------------------------- -# 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( - db, - title="Landing Test", - language="en", - ) - - destination = Destination( - form=form, - type="telegram", - reference="987654321", - ) - - submission = Submission( - form=form, - payload={"name": "Ivan"}, - ) - - Delivery( - submission=submission, - destination=destination, - ) - - db.add(form) - await db.commit() - - stored_form = await db.scalar(select(Form).order_by(Form.id)) - stored_destination = await db.scalar(select(Destination).order_by(Destination.id)) - stored_submission = await db.scalar(select(Submission).order_by(Submission.id)) - stored_delivery = await db.scalar(select(Delivery).order_by(Delivery.id)) - - assert stored_form is not None - assert stored_destination is not None - assert stored_submission is not None - assert stored_delivery is not None - - assert stored_form.title == "Landing Test" - assert stored_destination.type == "telegram" - assert stored_submission.payload == {"name": "Ivan"} - assert stored_delivery.status == DeliveryStatus.PENDING - - -@pytest.mark.asyncio -async def test_new_delivery_starts_pending_without_attempts( - db: AsyncSession, -): - form = await create_form( - db, - title="Landing Test", - language="en", - ) - - destination = Destination( - form=form, - type="telegram", - reference="987654321", - ) - - submission = Submission( - form=form, - payload={"name": "Ivan"}, - ) - - Delivery( - submission=submission, - destination=destination, - ) - - db.add(form) - await db.commit() - - result = await db.execute(select(Delivery).options(selectinload(Delivery.attempts))) - delivery = result.scalar_one() - - assert delivery.status == DeliveryStatus.PENDING - assert delivery.attempt_count == 0 - assert delivery.attempts == [] - - -@pytest.mark.asyncio -async def test_duplicate_delivery_failure(db: AsyncSession): - form = await create_form( - db, - title="Landing Test", - language="en", - ) - - destination = Destination( - form=form, - type="telegram", - reference="987654321", - ) - - submission = Submission( - form=form, - payload={"name": "Ivan"}, - ) - - Delivery( - submission=submission, - destination=destination, - ) - Delivery( - submission=submission, - destination=destination, - ) - - db.add(form) - - with pytest.raises(IntegrityError): - await db.commit() - - await db.rollback() - - forms = (await db.scalars(select(Form))).all() - submissions = (await db.scalars(select(Submission))).all() - deliveries = (await db.scalars(select(Delivery))).all() - - assert forms == [] - assert submissions == [] - assert deliveries == [] - - -# --------------------------------------------------------------------------- -# Delivery states -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_delivery_states_are_independent( - db: AsyncSession, -): - form = await create_form( - db, - title="Landing Test", - language="en", - ) - - destination_a = Destination( - form=form, - type="telegram", - reference="111111111", - ) - destination_b = Destination( - form=form, - type="telegram", - reference="222222222", - ) - destination_c = Destination( - form=form, - type="telegram", - reference="333333333", - ) - - submission = Submission( - form=form, - payload={"name": "Ivan"}, - ) - - delivery_a = Delivery( - submission=submission, - destination=destination_a, - ) - delivery_b = Delivery( - submission=submission, - destination=destination_b, - ) - delivery_c = Delivery( - submission=submission, - destination=destination_c, - ) - - db.add(form) - await db.commit() - - delivery_a.status = DeliveryStatus.SUCCEEDED - - delivery_b.status = DeliveryStatus.FAILED - delivery_b.failure_type = FailureType.PERMANENT - - delivery_c.status = DeliveryStatus.PENDING - - await db.commit() - - result = await db.execute( - select(Delivery).where(Delivery.submission_id == submission.id) - ) - deliveries = result.scalars().all() - - assert len(deliveries) == 3 - - statuses = {delivery.status for delivery in deliveries} - - assert statuses == { - DeliveryStatus.SUCCEEDED, - DeliveryStatus.FAILED, - DeliveryStatus.PENDING, - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status", - [ - DeliveryStatus.PENDING, - DeliveryStatus.SUCCEEDED, - DeliveryStatus.FAILED, - DeliveryStatus.AWAITING_RETRY, - DeliveryStatus.UNKNOWN, - ], -) -async def test_delivery_status_is_persisted( - db: AsyncSession, - status: DeliveryStatus, -): - delivery = await create_delivery( - db, - status=status, - ) - - result = await db.execute(select(Delivery).where(Delivery.id == delivery.id)) - stored_delivery = result.scalar_one() - - assert stored_delivery.status == status - - -# --------------------------------------------------------------------------- -# Retry selection -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_awaiting_retry_delivery_can_be_selected_when_due( - db: AsyncSession, -): - delivery = await create_delivery( - db, - status=DeliveryStatus.AWAITING_RETRY, - ) - - delivery.next_retry_at = datetime.now(timezone.utc) - timedelta(seconds=1) - await db.commit() - - now = datetime.now(timezone.utc) - - result = await db.execute( - select(Delivery).where( - Delivery.status == DeliveryStatus.AWAITING_RETRY, - Delivery.next_retry_at <= now, - ) - ) - selected_delivery = result.scalar_one() - - assert selected_delivery.id == delivery.id - - -@pytest.mark.asyncio -async def test_awaiting_retry_delivery_is_not_selected_before_due( - db: AsyncSession, -): - delivery = await create_delivery( - db, - status=DeliveryStatus.AWAITING_RETRY, - ) - - delivery.next_retry_at = datetime.now(timezone.utc) + timedelta(hours=1) - await db.commit() - - now = datetime.now(timezone.utc) - - result = await db.execute( - select(Delivery).where( - Delivery.status == DeliveryStatus.AWAITING_RETRY, - Delivery.next_retry_at <= now, - ) - ) - selected_delivery = result.scalar_one_or_none() - - assert selected_delivery is None - - -@pytest.mark.asyncio -async def test_retryable_failure_schedules_retry( - db: AsyncSession, -): - delivery = await create_delivery( - db, - DeliveryStatus.PROCESSING, - ) - delivery.attempt_count = 1 - delivery.processing_started_at = datetime.now(timezone.utc) - - attempt = DeliveryAttempt(delivery=delivery, trigger=delivery.queued_trigger) - db.add(attempt) - await db.commit() - - before = datetime.now(timezone.utc) - - await finish_delivery_attempt( - db, - delivery, - attempt, - { - "success": False, - "failure_type": "retryable_failure", - "error": "Telegram temporarily unavailable", - }, - ) - - after = datetime.now(timezone.utc) - - assert delivery.status == DeliveryStatus.AWAITING_RETRY - assert delivery.failure_type is None - assert delivery.last_error == "Telegram temporarily unavailable" - assert attempt.result == DeliveryAttemptResult.RETRYABLE_FAILURE - assert attempt.error == "Telegram temporarily unavailable" - assert attempt.finished_at is not None - assert delivery.next_retry_at is not None - assert before + timedelta(seconds=24) <= delivery.next_retry_at - assert delivery.next_retry_at <= after + timedelta(seconds=36) - assert delivery.processing_started_at is None - - -@pytest.mark.asyncio -async def test_retry_delay_increases_with_attempt_count( - db: AsyncSession, -): - delivery = await create_delivery( - db, - DeliveryStatus.PROCESSING, - ) - delivery.attempt_count = 3 - - attempt = DeliveryAttempt(delivery=delivery, trigger=delivery.queued_trigger) - db.add(attempt) - await db.commit() - - datetime.now(timezone.utc) - - await finish_delivery_attempt( - db, - delivery, - attempt, - { - "success": False, - "failure_type": "retryable_failure", - "error": "Temporary failure", - }, - ) - - after = datetime.now(timezone.utc) - - assert delivery.status == DeliveryStatus.AWAITING_RETRY - assert delivery.next_retry_at is not None - - assert after + timedelta(seconds=96) <= delivery.next_retry_at - assert delivery.next_retry_at <= after + timedelta(seconds=144) - - -@pytest.mark.asyncio -async def test_retryable_failure_exhausts_retries( - db: AsyncSession, -): - delivery = await create_delivery( - db, - DeliveryStatus.PROCESSING, - ) - delivery.attempt_count = 5 - delivery.next_retry_at = datetime.now(timezone.utc) - - attempt = DeliveryAttempt(delivery=delivery, trigger=delivery.queued_trigger) - db.add(attempt) - await db.commit() - - await finish_delivery_attempt( - db, - delivery, - attempt, - { - "success": False, - "failure_type": "retryable_failure", - "error": "Still unavailable", - }, - ) - - assert delivery.status == DeliveryStatus.FAILED - assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED - assert delivery.last_error == "Still unavailable" - assert delivery.processing_started_at is None - - assert attempt.result == DeliveryAttemptResult.RETRYABLE_FAILURE - assert attempt.error == "Still unavailable" - assert attempt.finished_at is not None - assert delivery.next_retry_at is None - - -@pytest.mark.asyncio -async def test_unexpected_exception_schedules_retry(db: AsyncSession, monkeypatch): - async def fake_execute_delivery_attempt(destination, message): - raise RuntimeError("Test Failure") - - monkeypatch.setattr( - "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt - ) - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - - with pytest.raises(RuntimeError): - await process_delivery(delivery.id, db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery.status == DeliveryStatus.AWAITING_RETRY - assert delivery.attempts[-1].result == DeliveryAttemptResult.UNKNOWN - assert delivery.attempts[-1].finished_at is not None - assert delivery.next_retry_at is not None - assert delivery.processing_started_at is None - - -@pytest.mark.asyncio -async def test_unexpected_exception_respects_max_attempts( - db: AsyncSession, monkeypatch -): - async def fake_execute_delivery_attempt(destination, message): - raise RuntimeError("Test Failure") - - monkeypatch.setattr( - "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt - ) - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - delivery.attempt_count = MAX_DELIVERY_ATTEMPTS - 1 - - with pytest.raises(RuntimeError): - await process_delivery(delivery.id, db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery.status == DeliveryStatus.FAILED - assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED - assert delivery.attempts[-1].result == DeliveryAttemptResult.UNKNOWN - assert delivery.next_retry_at is None and delivery.processing_started_at is None - - -@pytest.mark.asyncio -async def test_successful_processing_still_works(db: AsyncSession, monkeypatch): - async def fake_execute_delivery_attempt(destination, message): - return {"success": True, "external_reference": str(12334567)} - - monkeypatch.setattr( - "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt - ) - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - delivery.attempt_count = MAX_DELIVERY_ATTEMPTS - 1 - - await process_delivery(delivery.id, db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - assert delivery is not None - assert delivery.status == DeliveryStatus.SUCCEEDED - assert delivery.attempts[-1].result == DeliveryAttemptResult.SUCCEEDED - assert delivery.delivered_at is not None - assert delivery.processing_started_at is None - - -@pytest.mark.asyncio -async def test_permanent_failre_still_works(db: AsyncSession, monkeypatch): - async def fake_execute_delivery_attempt(destination, message): - return { - "success": False, - "error": "Chat not found", - "failure_type": "permanent_failure", - } - - monkeypatch.setattr( - "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt - ) - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - - await process_delivery(delivery.id, db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery is not None - assert delivery.status == DeliveryStatus.FAILED - assert delivery.failure_type == FailureType.PERMANENT - assert delivery.attempts[-1].result == DeliveryAttemptResult.PERMANENT_FAILURE - assert delivery.next_retry_at is None - - -@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) - - recovery_amount = await recovery_stale_deliveries(db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery is not None - assert delivery.status == DeliveryStatus.AWAITING_RETRY - assert delivery.next_retry_at is not None - assert delivery.processing_started_at is None - assert recovery_amount == 1 - - -@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) - - recovery_amount = await recovery_stale_deliveries(db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery is not None - assert delivery.status == DeliveryStatus.PROCESSING - assert recovery_amount == 0 - - -@pytest.mark.asyncio -async def test_non_processing_deliveries_are_untouched(db: AsyncSession): - deliveries = [ - await create_delivery(db, DeliveryStatus.SUCCEEDED), - await create_delivery(db, DeliveryStatus.FAILED), - await create_delivery(db, DeliveryStatus.AWAITING_RETRY), - ] - for delivery in deliveries: - delivery.processing_started_at = datetime.now(timezone.utc) - timedelta( - minutes=10 - ) - - expected_statuses = {delivery.id: delivery.status for delivery in deliveries} - recovery_amount = await recovery_stale_deliveries(db) - - deliveries = await db.scalars( - select(Delivery).options(selectinload(Delivery.attempts)) - ) - for delivery in deliveries.all(): - assert delivery is not None - assert delivery.status == expected_statuses[delivery.id] - - assert recovery_amount == 0 - - -@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) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery is not None - assert delivery.status == DeliveryStatus.FAILED - assert delivery.failure_type == FailureType.RETRIES_EXHAUSTED - assert delivery.next_retry_at is None - assert delivery.processing_started_at is None - - -@pytest.mark.asyncio -async def test_recovered_delivery_can_be_claimed(db: AsyncSession): - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) - delivery.processing_started_at = datetime.now(timezone.utc) - timedelta(minutes=10) - - await recovery_stale_deliveries(db) - - delivery = await db.scalar( - select(Delivery) - .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery.id) - ) - - assert delivery is not None - assert delivery.status == DeliveryStatus.AWAITING_RETRY - assert delivery.next_retry_at is not None - assert delivery.processing_started_at is None - - delivery = await claim_next_delivery(db) - - assert delivery is not None - assert delivery.status == DeliveryStatus.PROCESSING - assert delivery.processing_started_at is not None - - -@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"}, - ) - 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, -): - 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"}, - ) - 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, -): - 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), - ) - - 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, -): - form = await create_form(db, title="Test form") - - 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, -): - 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) - ] - - 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, -): - 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_get_submission_returns_submission_with_payload( - 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() - - 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, -): - 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() - - 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, -): - delivery = await create_delivery(db, DeliveryStatus.AWAITING_RETRY) - - 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, -): - response = await client.get("/api/submissions/999999") - - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_get_submission_invalid_id( - client: AsyncClient, -): - response = await client.get("/api/submissions/not-an-id") - - assert response.status_code == 422 - - -@pytest.mark.asyncio -async def test_get_forms_returns_forms( - 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") - - 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), - } - - -@pytest.mark.asyncio -async def test_get_forms_are_ordered_newest_first( - db: AsyncSession, - client: AsyncClient, -): - now = datetime.now(timezone.utc) - - old_form = await create_form( - db, - title="Old form", - created_at=now - timedelta(hours=1), - ) - new_form = await create_form( - db, - title="New form", - 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 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), - ) - - second_attempt = DeliveryAttempt( - delivery=delivery, - trigger=DeliveryTrigger.RETRY, - created_at=now - timedelta(minutes=1), - ) - - 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, - status=DeliveryStatus.FAILED, - ) - 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) - - 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( - db, - status=DeliveryStatus.UNKNOWN, - ) - - result = await manual_delivery(delivery.id, db) - - assert result is not None - 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( - db: AsyncSession, -): - with pytest.raises(DeliveryNotFoundError): - await manual_delivery(999999, 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( - db, - status=status, - ) - - 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( - db, - status=DeliveryStatus.SUCCEEDED, - ) - - 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( - db, - status=DeliveryStatus.FAILED, - ) - - response = await client.post(f"/api/deliveries/{delivery.id}/replay") - - assert response.status_code == 202 - - data = response.json() - assert data["id"] == delivery.id - assert data["status"] == DeliveryStatus.PENDING.value - - 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 - - -@pytest.mark.asyncio -async def test_replay_unknown_delivery(client, db: AsyncSession): - delivery = await create_delivery( - db, - status=DeliveryStatus.UNKNOWN, - ) - - 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( - db, - status=status, - ) - - 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 - - -@pytest.mark.asyncio -async def test_manual_replay_is_recorded_in_attempt_history( - client, - db: AsyncSession, - monkeypatch, -): - delivery = await create_delivery( - db, - status=DeliveryStatus.FAILED, - ) - - 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 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", - } - - monkeypatch.setattr( - "app.services.delivery.execute_delivery_attempt", - fake_execute_delivery_attempt, - ) - - await process_delivery( - delivery_id=delivery.id, - db=db, - ) - - result = await db.execute( - select(DeliveryAttempt).where(DeliveryAttempt.delivery_id == delivery.id) - ) - 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, - ) - - 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, - ) - - db.add_all([form_a, form_b]) - 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_workspace_relationship_persists_after_reload( - db: AsyncSession, -) -> None: - workspace = Workspace(name="Persistent Workspace") - form = Form( - title="Demo Request", - workspace=workspace, - ) - - db.add(form) - await db.commit() - - workspace_id = workspace.id - form_id = form.id - - 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, - ) - - db.add(form) - await db.commit() - - form_id = form.id - - await db.delete(workspace) - await db.commit() - - result = await db.execute(select(Form).where(Form.id == form_id)) - - 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() - - await db.rollback() diff --git a/tests/test_form.py b/tests/test_form.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..b9daf41 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,327 @@ +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Form, Submission, Workspace +from tests.conftest import create_form + + +@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, +): + form = await create_form(db, title="Test form") + + 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, +): + 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"}, + ) + 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, +): + 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), + ) + + 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, +): + form = await create_form(db, title="Test form") + + 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, +): + 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) + ] + + 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, +): + 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_get_forms_returns_forms( + 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") + + 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), + } + + +@pytest.mark.asyncio +async def test_get_forms_are_ordered_newest_first( + db: AsyncSession, + client: AsyncClient, +): + now = datetime.now(timezone.utc) + + old_form = await create_form( + db, + title="Old form", + created_at=now - timedelta(hours=1), + ) + new_form = await create_form( + db, + title="New form", + 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 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_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() diff --git a/tests/test_replay.py b/tests/test_replay.py new file mode 100644 index 0000000..83e79a2 --- /dev/null +++ b/tests/test_replay.py @@ -0,0 +1,104 @@ +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, +) +from app.services.delivery import ( + DeliveryNotFoundError, + DeliveryNotReplayableError, + claim_next_delivery, + manual_delivery, + process_delivery, +) +from tests.conftest import create_delivery + + +@pytest.mark.asyncio +async def test_failed_delivery_can_be_queued_for_manual_replay(db: AsyncSession): + delivery = await create_delivery(db, DeliveryStatus.FAILED) + delivery.attempt_count = 5 + delivery.next_retry_at = datetime.now(timezone.utc) + await db.commit() + + result = await manual_delivery(delivery.id, db) + assert result.status == DeliveryStatus.PENDING + assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY + assert result.failure_type 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(db, DeliveryStatus.UNKNOWN) + result = await manual_delivery(delivery.id, db) + assert result.status == DeliveryStatus.PENDING + assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY + + +@pytest.mark.asyncio +async def test_manual_replay_raises_not_found_for_missing_delivery(db: AsyncSession): + with pytest.raises(DeliveryNotFoundError): + await manual_delivery(999999, db) + + +@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, status): + delivery = await create_delivery(db, status) + with pytest.raises(DeliveryNotReplayableError): + await manual_delivery(delivery.id, db) + + +@pytest.mark.asyncio +async def test_replay_failed_delivery_endpoint(client, db: AsyncSession): + delivery = await create_delivery(db, DeliveryStatus.FAILED) + 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): + 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, +): + delivery = await create_delivery(db, DeliveryStatus.FAILED) + 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) + + result = await db.scalars( + select(DeliveryAttempt).where(DeliveryAttempt.delivery_id == delivery.id) + ) + attempt = result.one() + assert attempt.trigger == DeliveryTrigger.MANUAL_REPLAY + assert attempt.result == DeliveryAttemptResult.SUCCEEDED diff --git a/tests/test_submission.py b/tests/test_submission.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_submissions.py b/tests/test_submissions.py new file mode 100644 index 0000000..e20a6f9 --- /dev/null +++ b/tests/test_submissions.py @@ -0,0 +1,96 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DeliveryStatus +from tests.conftest import create_delivery + + +@pytest.mark.asyncio +async def test_get_submission_returns_submission_with_payload( + 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() + + 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, +): + 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() + + 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, +): + delivery = await create_delivery(db, DeliveryStatus.AWAITING_RETRY) + + 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, +): + response = await client.get("/api/submissions/999999") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_submission_invalid_id( + client: AsyncClient, +): + response = await client.get("/api/submissions/not-an-id") + + assert response.status_code == 422 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/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" From e0ce2f1e227a699802f274be4ceed39b88bb0446 Mon Sep 17 00:00:00 2001 From: loppify Date: Thu, 24 Sep 2026 11:57:05 +0200 Subject: [PATCH 2/4] auth boundary set --- app/api/auth.py | 96 ++-- app/api/deliveries.py | 26 +- app/api/forms.py | 38 +- app/api/submissions.py | 9 +- app/api/workspaces.py | 17 + app/database/queries/auth.py | 13 +- app/database/queries/deliveries.py | 27 +- app/database/queries/forms.py | 30 +- app/database/queries/submissions.py | 13 +- app/database/queries/workspace.py | 31 ++ app/database/session.py | 1 - app/main.py | 8 +- app/schemas/workspaces.py | 8 + app/services/auth.py | 20 +- app/services/delivery.py | 31 +- app/services/workspace.py | 29 ++ tests/conftest.py | 131 ++++- tests/helpers.py | 0 tests/test_auth.py | 51 +- tests/test_deliveries.py | 499 +++++++++++++++++- tests/test_delivery.py | 0 tests/test_form.py | 0 tests/test_forms.py | 754 +++++++++++++++++++++++++--- tests/test_replay.py | 233 +++++++-- tests/test_submission.py | 0 tests/test_submissions.py | 377 +++++++++++++- tests/test_workspaces.py | 269 ++++++++++ 27 files changed, 2440 insertions(+), 271 deletions(-) create mode 100644 app/api/workspaces.py create mode 100644 app/database/queries/workspace.py create mode 100644 app/schemas/workspaces.py create mode 100644 app/services/workspace.py delete mode 100644 tests/helpers.py delete mode 100644 tests/test_delivery.py delete mode 100644 tests/test_form.py delete mode 100644 tests/test_submission.py create mode 100644 tests/test_workspaces.py diff --git a/app/api/auth.py b/app/api/auth.py index cf55808..0ad9e76 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,62 +1,85 @@ -from urllib import response - -from fastapi import APIRouter, Depends, HTTPException, Response, status, Cookie +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 register_user, UserAlreadyExistsError, create_session, login_user, \ - InvalidCredentialsError, get_user_by_session_token, logout_session +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)): +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") + 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="/") + 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)): +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="/") + 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: +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") + 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") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User is not authenticated" + ) return user @@ -67,13 +90,14 @@ async def get_current_user_handler(user: User = Depends(get_current_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)): +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="/") + 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 050f072..6ea893d 100644 --- a/app/api/deliveries.py +++ b/app/api/deliveries.py @@ -1,13 +1,15 @@ 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,8 +19,9 @@ 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( @@ -30,13 +33,20 @@ async def get_detailed_delivery( @router.post( - "/{delivery_id}/replay", response_model=DeliveryDetailRead, status_code=status.HTTP_202_ACCEPTED + "/{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=status.HTTP_404_NOT_FOUND, detail="Delivery not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Delivery not found" + ) except DeliveryNotReplayableError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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 6648c71..c6e8e02 100644 --- a/app/api/forms.py +++ b/app/api/forms.py @@ -4,15 +4,17 @@ 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.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"]) @@ -20,9 +22,9 @@ @router.post("", response_model=FormRead, status_code=status.HTTP_201_CREATED) async def create_form_endpoint( - data: FormCreate, - locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)], - db: AsyncSession = Depends(get_db), + data: FormCreate, + locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)], + db: AsyncSession = Depends(get_db), ): current_lang, _ = locale lang = data.language if data.language in SUPPORTED_LANGUAGES else current_lang @@ -30,17 +32,20 @@ async def create_form_endpoint( return await create_form(db, data.title, lang, data.destinations) -@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), + 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=status.HTTP_404_NOT_FOUND, 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 @@ -49,8 +54,9 @@ async def get_form_submissions( @router.get("", response_model=list[FormRead]) 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), + 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 b4872b1..7bf3574 100644 --- a/app/api/submissions.py +++ b/app/api/submissions.py @@ -1,7 +1,9 @@ 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,9 +14,10 @@ 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: 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 index db04a67..2f424cb 100644 --- a/app/database/queries/auth.py +++ b/app/database/queries/auth.py @@ -1,15 +1,18 @@ -import datetime - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import User, Session +from app.database.models import Session, User -async def get_session_by_token_hash(db: AsyncSession, token_hash: str) -> Session | None: +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)) + select(Session) + .options(selectinload(Session.user)) + .where(Session.token_hash == token_hash) + ) return session.scalar_one_or_none() diff --git a/app/database/queries/deliveries.py b/app/database/queries/deliveries.py index dacf0a1..7790801 100644 --- a/app/database/queries/deliveries.py +++ b/app/database/queries/deliveries.py @@ -1,13 +1,34 @@ +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, Submission, Form, 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..8ba0b0c --- /dev/null +++ b/app/database/queries/workspace.py @@ -0,0 +1,31 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import Workspace, WorkspaceMembership + + +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()] diff --git a/app/database/session.py b/app/database/session.py index 2c4f27e..bdeb56c 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -18,4 +18,3 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: except Exception: await session.rollback() raise - diff --git a/app/main.py b/app/main.py index 8e904b3..76f2bad 100644 --- a/app/main.py +++ b/app/main.py @@ -6,11 +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.auth import router as auth_router +from app.api.workspaces import router as workspaces_router from app.core.i18n import ( SUPPORTED_LANGUAGES, get_locale, @@ -34,11 +35,12 @@ async def lifespan(app: FastAPI): 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) async def render_index( - request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] + request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] ): language, translations = locale @@ -65,7 +67,7 @@ async def render_index( @app.get("/success", response_class=HTMLResponse) async def render_success( - request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] + request: Request, locale: Annotated[tuple[str, dict[str, str]], Depends(get_locale)] ): language, translations = locale 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 a6203e9..acd08b7 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,14 +1,17 @@ import hashlib import secrets -from datetime import timedelta, datetime, UTC +from datetime import UTC, datetime, timedelta from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError, InvalidHashError -from sqlalchemy import func +from argon2.exceptions import InvalidHashError, VerifyMismatchError from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, Session -from app.database.queries.auth import get_user_by_email, get_session_by_token_hash, delete_session_token_by_token_hash +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, +) password_hasher = PasswordHasher() SESSION_TTL = 30 @@ -43,7 +46,7 @@ async def create_session(db: AsyncSession, user: User) -> str: session = Session( token_hash=hashed_token, expires_at=datetime.now(UTC) + timedelta(days=SESSION_TTL), - user=user + user=user, ) db.add(session) @@ -65,10 +68,7 @@ async def register_user(db: AsyncSession, email: str, password: str) -> User: if await get_user_by_email(db, normalized_email) is not None: raise UserAlreadyExistsError - user = User( - email=normalized_email, - password_hash=hash_password(password) - ) + user = User(email=normalized_email, password_hash=hash_password(password)) db.add(user) await db.flush() return user diff --git a/app/services/delivery.py b/app/services/delivery.py index 2661fbe..049cfb6 100644 --- a/app/services/delivery.py +++ b/app/services/delivery.py @@ -20,6 +20,7 @@ Form, Submission, ) +from app.database.queries.deliveries import get_accessible_delivery_by_id, get_delivery_for_processing from app.services.telegram import format_submission_message, send_telegram_alert MAX_DELIVERY_ATTEMPTS = 5 @@ -63,20 +64,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 +71,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 @@ -95,7 +81,7 @@ async def execute_delivery_attempt(destination: Destination, message: str): async def finish_delivery_attempt( - db: AsyncSession, delivery: Delivery, delivery_attempt: DeliveryAttempt, res: dict + db: AsyncSession, delivery: Delivery, delivery_attempt: DeliveryAttempt, res: dict ) -> None: log = logger.bind( delivery_id=delivery.id, @@ -163,7 +149,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") @@ -289,14 +275,14 @@ 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 if delivery.status not in ( - DeliveryStatus.FAILED, - DeliveryStatus.UNKNOWN, + DeliveryStatus.FAILED, + DeliveryStatus.UNKNOWN, ): raise DeliveryNotReplayableError @@ -305,6 +291,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/workspace.py b/app/services/workspace.py new file mode 100644 index 0000000..a9f4467 --- /dev/null +++ b/app/services/workspace.py @@ -0,0 +1,29 @@ +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, +) + + +class WorkspaceAccessDeniedError(Exception): + pass + + +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/tests/conftest.py b/tests/conftest.py index bd8b886..049e427 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,8 +15,12 @@ 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 @@ -44,8 +48,8 @@ async def client(): transport = ASGITransport(app=app) async with AsyncClient( - transport=transport, - base_url="https://test", + transport=transport, + base_url="https://test", ) as client: yield client @@ -86,13 +90,14 @@ async def db(): async def create_form( - db: AsyncSession, - *, - title: str = "Test form", - language: str = "en", - created_at=None, + db: AsyncSession, + *, + title: str = "Test form", + language: str = "en", + workspace: Workspace | None = None, + created_at=None, ) -> Form: - workspace = Workspace(name="Test Workspace") + 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: @@ -102,10 +107,11 @@ async def create_form( async def create_delivery( - db: AsyncSession, - status: DeliveryStatus = DeliveryStatus.PENDING, + db: AsyncSession, + status: DeliveryStatus = DeliveryStatus.PENDING, + workspace: Workspace | None = None, ) -> Delivery: - form = await create_form(db) + form = await create_form(db, workspace=workspace) destination = Destination( form=form, type="telegram", @@ -128,3 +134,106 @@ async def create_delivery( 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/helpers.py b/tests/helpers.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_auth.py b/tests/test_auth.py index b44de99..78492df 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,13 +5,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Session, User -from app.services.auth import get_user_by_session_token, login_user, InvalidCredentialsError, logout_session 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, ) @@ -109,9 +112,7 @@ async def test_create_session_stores_hashed_token(db: AsyncSession): token = await create_session(db, user) - result = await db.execute( - select(Session).where(Session.user_id == user.id) - ) + result = await db.execute(select(Session).where(Session.user_id == user.id)) session = result.scalar_one() assert token @@ -130,9 +131,7 @@ async def test_create_session_sets_expiration(db: AsyncSession): after = datetime.now(UTC) - result = await db.execute( - select(Session).where(Session.user_id == user.id) - ) + result = await db.execute(select(Session).where(Session.user_id == user.id)) session = result.scalar_one() assert before + timedelta(days=30) <= session.expires_at @@ -153,7 +152,7 @@ async def test_get_user_by_session_token_returns_user(db: AsyncSession): async def test_get_user_by_session_token_returns_none_for_unknown_token( - db: AsyncSession, + db: AsyncSession, ): result = await get_user_by_session_token( db, @@ -164,7 +163,7 @@ async def test_get_user_by_session_token_returns_none_for_unknown_token( async def test_get_user_by_session_token_rejects_expired_session( - db: AsyncSession, + db: AsyncSession, ): user = User(email="test@example.com") db.add(user) @@ -192,9 +191,7 @@ async def test_register_user_creates_user(db: AsyncSession): "strong-password", ) - stored_user = await db.scalar( - select(User).where(User.id == user.id) - ) + 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" @@ -240,7 +237,7 @@ async def test_register_user_rejects_duplicate_email(db: AsyncSession): async def test_register_user_rejects_duplicate_normalized_email( - db: AsyncSession, + db: AsyncSession, ): await register_user( db, @@ -275,9 +272,7 @@ async def test_login_user_returns_user_and_session_token(db: AsyncSession): assert isinstance(user, User) assert token - session = await db.scalar( - select(Session).where(Session.user_id == user.id) - ) + 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) @@ -352,16 +347,14 @@ async def test_logout_session_deletes_session(db: AsyncSession): await logout_session(db, token) session = await db.scalar( - select(Session).where( - Session.token_hash == hash_session_token(token) - ) + 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, + db: AsyncSession, ): user = await register_user( db, @@ -375,14 +368,10 @@ async def test_logout_session_does_not_delete_other_sessions( await logout_session(db, first_token) first_session = await db.scalar( - select(Session).where( - Session.token_hash == hash_session_token(first_token) - ) + 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) - ) + select(Session).where(Session.token_hash == hash_session_token(second_token)) ) assert first_session is None @@ -390,7 +379,7 @@ async def test_logout_session_does_not_delete_other_sessions( async def test_logout_session_accepts_unknown_token( - db: AsyncSession, + db: AsyncSession, ): await logout_session( db, @@ -646,7 +635,7 @@ async def test_me_rejects_expired_session(client, db: AsyncSession): password_hash=hash_password("strong-password"), ) db.add(user) - await db.flush() + await db.commit() token = generate_session_token() @@ -656,7 +645,7 @@ async def test_me_rejects_expired_session(client, db: AsyncSession): expires_at=datetime.now(UTC) - timedelta(seconds=1), ) db.add(session) - await db.flush() + await db.commit() client.cookies.set( "ackvia_session", @@ -716,9 +705,7 @@ async def test_logout_without_session_is_idempotent(client): async def test_logout_with_invalid_session_is_idempotent(client): - client.cookies.set("invalid_session_token", - "ackvia_session", - path="/") + client.cookies.set("invalid_session_token", "ackvia_session", path="/") response = await client.post("/api/auth/logout") diff --git a/tests/test_deliveries.py b/tests/test_deliveries.py index 501808f..8a19d46 100644 --- a/tests/test_deliveries.py +++ b/tests/test_deliveries.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta, timezone import pytest +from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -15,16 +16,18 @@ Destination, FailureType, Form, - Submission, + Submission, WorkspaceRole, ) +from app.database.queries.deliveries import get_accessible_delivery_by_id from app.services.delivery import ( MAX_DELIVERY_ATTEMPTS, claim_next_delivery, finish_delivery_attempt, - process_delivery, + process_delivery, DeliveryNotFoundError, queue_manual_replay, ) from app.workers.delivery import recovery_stale_deliveries -from tests.conftest import create_delivery, create_form +from tests.conftest import create_delivery, create_form, create_submission, create_user, create_workspace, \ + create_membership, register_and_get_user, create_authenticated_workspace @pytest.mark.asyncio @@ -633,7 +636,28 @@ async def test_recovered_delivery_can_be_claimed(db: AsyncSession): @pytest.mark.asyncio async def test_get_delivery_returns_delivery(db: AsyncSession, client): - delivery = await create_delivery(db) + 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() + + delivery = await create_delivery(db, workspace=workspace) response = await client.get(f"/api/deliveries/{delivery.id}") assert response.status_code == 200 @@ -643,7 +667,28 @@ async def test_get_delivery_returns_delivery(db: AsyncSession, client): @pytest.mark.asyncio async def test_get_delivery_returns_attempts(db: AsyncSession, client): - delivery = await create_delivery(db, DeliveryStatus.PROCESSING) + 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() + + delivery = await create_delivery(db, DeliveryStatus.PROCESSING, workspace=workspace) attempt = DeliveryAttempt( delivery=delivery, result=DeliveryAttemptResult.RETRYABLE_FAILURE, @@ -657,15 +702,453 @@ async def test_get_delivery_returns_attempts(db: AsyncSession, client): response = await client.get(f"/api/deliveries/{delivery.id}") data = response.json() + assert response.status_code == 200 assert data["attempt_count"] == 1 assert ( - data["attempts"][0]["result"] - == DeliveryAttemptResult.RETRYABLE_FAILURE.value + data["attempts"][0]["result"] == DeliveryAttemptResult.RETRYABLE_FAILURE.value ) @pytest.mark.asyncio -async def test_get_delivery_returns_404_when_not_found(client): +async def test_get_delivery_returns_404_when_not_found(client, db: AsyncSession): + await create_authenticated_workspace( + client, + db, + ) + + await db.commit() response = await client.get("/api/deliveries/999999") assert response.status_code == 404 + + +async def test_get_accessible_delivery_by_id_returns_delivery_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, + ) + + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + 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 result is not None + assert result.id == delivery.id + + +async def test_get_accessible_delivery_by_id_returns_none_for_foreign_workspace( + db: AsyncSession, +): + user = await create_user(db) + + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() + + submission = await create_submission( + db, + form=foreign_form, + ) + + destination = Destination( + form=foreign_form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + 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 result is None + + +async def test_get_accessible_delivery_by_id_returns_none_for_unknown_delivery( + db: AsyncSession, +): + user = await create_user(db) + + result = await get_accessible_delivery_by_id( + db, + 999999999, + user.id, + ) + + assert result is None + + +async def test_get_delivery_returns_accessible_delivery( + 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, + ) + + form = await create_form(db, workspace=workspace) + db.add(form) + await db.flush() + + submission = await create_submission(db, form=form) + + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + delivery = Delivery( + submission=submission, + destination=destination, + ) + db.add(delivery) + + await db.commit() + + response = await client.get( + f"/api/deliveries/{delivery.id}" + ) + + assert response.status_code == 200 + assert response.json()["id"] == delivery.id + + +@pytest.mark.asyncio +async def test_get_delivery_hides_foreign_workspace_delivery( + db: AsyncSession, + client: AsyncClient, +): + await create_authenticated_workspace(client, db) + + foreign_workspace = await create_workspace(db, name="Foreign") + delivery = await create_delivery( + db, + workspace=foreign_workspace, + ) + + response = await client.get( + f"/api/deliveries/{delivery.id}" + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_delivery_requires_authentication( + db: AsyncSession, + client: AsyncClient, +): + workspace = await create_workspace(db) + delivery = await create_delivery( + db, + workspace=workspace, + ) + + client.cookies.clear() + + response = await client.get( + f"/api/deliveries/{delivery.id}" + ) + + assert response.status_code == 401 + + +async def test_manual_replay_allows_workspace_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, + ) + + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + ) + db.add(delivery) + await db.flush() + + result = await queue_manual_replay( + db, + delivery.id, + user.id, + ) + + assert result.id == delivery.id + assert result.status == DeliveryStatus.PENDING + assert result.queued_trigger == DeliveryTrigger.MANUAL_REPLAY + + +async def test_manual_replay_rejects_foreign_workspace_delivery( + db: AsyncSession, +): + user = await create_user(db) + + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() + + submission = await create_submission( + db, + form=foreign_form, + ) + + destination = Destination( + form=foreign_form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + ) + db.add(delivery) + await db.flush() + + with pytest.raises(DeliveryNotFoundError): + await queue_manual_replay( + db, + delivery.id, + user.id, + ) + + +async def test_rejected_foreign_replay_preserves_delivery( + db: AsyncSession, +): + user = await create_user(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, + ) + + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + last_error="Telegram rejected request", + ) + db.add(delivery) + await db.flush() + + 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 == FailureType.PERMANENT + assert delivery.last_error == "Telegram rejected request" + + +async def test_replay_foreign_delivery_returns_404( + db: AsyncSession, + client: AsyncClient, +): + await register_and_get_user(client, db) + + foreign_workspace = await create_workspace( + db, + name="Foreign", + ) + + foreign_form = await create_form( + db, + workspace=foreign_workspace, + ) + db.add(foreign_form) + await db.flush() + + submission = await create_submission( + db, + form=foreign_form, + ) + + destination = Destination( + form=foreign_form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + ) + db.add(delivery) + + await db.commit() + + response = await client.post( + f"/api/deliveries/{delivery.id}/replay" + ) + + assert response.status_code == 404 + + +async def test_replay_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) + + destination = Destination( + form=form, + type="telegram", + reference="123456", + ) + db.add(destination) + await db.flush() + + delivery = Delivery( + submission=submission, + destination=destination, + status=DeliveryStatus.FAILED, + failure_type=FailureType.PERMANENT, + ) + db.add(delivery) + + await db.commit() + + client.cookies.clear() + + response = await client.post( + f"/api/deliveries/{delivery.id}/replay" + ) + + assert response.status_code == 401 diff --git a/tests/test_delivery.py b/tests/test_delivery.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_form.py b/tests/test_form.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_forms.py b/tests/test_forms.py index b9daf41..e85cefb 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -5,8 +5,18 @@ from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import Form, Submission, Workspace -from tests.conftest import create_form +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 @@ -24,10 +34,14 @@ async def test_invalid_form_uuid(client: AsyncClient): @pytest.mark.asyncio async def test_get_form_submissions_returns_only_its_submissions( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - form = await create_form(db, title="Test form") + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) submission_1 = Submission( form=form, @@ -56,11 +70,15 @@ async def test_get_form_submissions_returns_only_its_submissions( @pytest.mark.asyncio async def test_get_form_submissions_does_not_return_other_form_submissions( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - form_1 = await create_form(db, title="Form 1") - form_2 = await create_form(db, title="Form 2") + _, 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, @@ -87,10 +105,14 @@ async def test_get_form_submissions_does_not_return_other_form_submissions( @pytest.mark.asyncio async def test_get_form_submissions_are_ordered_newest_first( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - form = await create_form(db, title="Test form") + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) old_submission = Submission( form=form, @@ -119,10 +141,14 @@ async def test_get_form_submissions_are_ordered_newest_first( @pytest.mark.asyncio async def test_get_form_submissions_respects_limit( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - form = await create_form(db, title="Test form") + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) for i in range(5): Submission( @@ -147,10 +173,14 @@ async def test_get_form_submissions_respects_limit( @pytest.mark.asyncio async def test_get_form_submissions_respects_offset( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - form = await create_form(db, title="Test form") + _, workspace = await create_authenticated_workspace( + client, + db, + ) + form = await create_form(db, title="Test form", workspace=workspace) now = datetime.now(timezone.utc) @@ -185,8 +215,12 @@ async def test_get_form_submissions_respects_offset( @pytest.mark.asyncio async def test_get_form_submissions_returns_404_for_unknown_form( - client: AsyncClient, + client: AsyncClient, db: AsyncSession ): + await create_authenticated_workspace( + client, + db, + ) form_id = uuid.uuid4() response = await client.get(f"/api/forms/{form_id}/submissions") @@ -195,12 +229,261 @@ async def test_get_form_submissions_returns_404_for_unknown_form( @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, + db: AsyncSession, + client: AsyncClient, ): - form_1 = await create_form(db, title="Form 1") - form_2 = await create_form(db, title="Form 2") + 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() @@ -218,21 +501,32 @@ async def test_get_forms_returns_forms( } -@pytest.mark.asyncio async def test_get_forms_are_ordered_newest_first( - db: AsyncSession, - client: AsyncClient, + 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, ) @@ -245,22 +539,33 @@ async def test_get_forms_are_ordered_newest_first( data = response.json() - assert len(data) == 2 - assert data[0]["id"] == str(new_form.id) - assert data[1]["id"] == str(old_form.id) + assert [item["id"] for item in data] == [ + str(new_form.id), + str(old_form.id), + ] -@pytest.mark.asyncio async def test_get_forms_respects_limit_and_offset( - db: AsyncSession, - client: AsyncClient, + 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) @@ -281,47 +586,382 @@ async def test_get_forms_respects_limit_and_offset( data = response.json() - assert len(data) == 2 - assert data[0]["id"] == str(forms[3].id) - assert data[1]["id"] == str(forms[2].id) + assert [item["id"] for item in data] == [ + str(forms[3].id), + str(forms[2].id), + ] -@pytest.mark.asyncio async def test_get_forms_returns_empty_list_when_no_forms_exist( - client: AsyncClient, + 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() == [] -@pytest.mark.asyncio -async def test_form_belongs_to_workspace(db: AsyncSession) -> None: - form = await create_form(db, title="Contact Form") +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() - assert form.workspace_id == form.workspace.id + response = await client.get("/api/forms") + assert response.status_code == 200 -@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]) + 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() - await db.refresh(workspace, ["forms"]) - assert len(workspace.forms) == 2 - assert {form.title for form in workspace.forms} == {"Contact Form", "Quote Form"} + response = await client.get("/api/forms") + assert response.status_code == 200 -@pytest.mark.asyncio -async def test_form_cannot_exist_without_workspace(db: AsyncSession) -> None: - from app.database.models import Form + ids = {item["id"] for item in response.json()} + assert str(form.id) in ids - db.add(Form(title="Orphan Form")) - with pytest.raises(Exception): - await db.commit() - await db.rollback() + +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 diff --git a/tests/test_replay.py b/tests/test_replay.py index 83e79a2..f59918f 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -8,78 +8,184 @@ DeliveryAttempt, DeliveryAttemptResult, DeliveryStatus, - DeliveryTrigger, + DeliveryTrigger, WorkspaceRole, ) from app.services.delivery import ( DeliveryNotFoundError, DeliveryNotReplayableError, claim_next_delivery, - manual_delivery, - process_delivery, + process_delivery, queue_manual_replay, ) -from tests.conftest import create_delivery +from tests.conftest import create_delivery, create_user, create_workspace, create_membership, \ + create_authenticated_workspace @pytest.mark.asyncio -async def test_failed_delivery_can_be_queued_for_manual_replay(db: AsyncSession): - delivery = await create_delivery(db, DeliveryStatus.FAILED) +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.commit() - result = await manual_delivery(delivery.id, db) + 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): - delivery = await create_delivery(db, DeliveryStatus.UNKNOWN) - result = await manual_delivery(delivery.id, db) +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): +async def test_manual_replay_raises_not_found_for_missing_delivery( + db: AsyncSession, +): + user = await create_user(db) + with pytest.raises(DeliveryNotFoundError): - await manual_delivery(999999, db) + 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, status): - delivery = await create_delivery(db, status) +@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 manual_delivery(delivery.id, db) + await queue_manual_replay( + db, + delivery.id, + user.id, + ) @pytest.mark.asyncio async def test_replay_failed_delivery_endpoint(client, db: AsyncSession): - delivery = await create_delivery(db, DeliveryStatus.FAILED) + 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): +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, + client, + db: AsyncSession, + monkeypatch, ): - delivery = await create_delivery(db, DeliveryStatus.FAILED) + 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 @@ -94,11 +200,80 @@ async def fake_execute_delivery_attempt(destination, message): "app.services.delivery.execute_delivery_attempt", fake_execute_delivery_attempt, ) + await process_delivery(delivery.id, db) - result = await db.scalars( - select(DeliveryAttempt).where(DeliveryAttempt.delivery_id == delivery.id) + attempt = await db.scalar( + select(DeliveryAttempt).where( + DeliveryAttempt.delivery_id == delivery.id + ) ) - attempt = result.one() + 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_submission.py b/tests/test_submission.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_submissions.py b/tests/test_submissions.py index e20a6f9..35584a6 100644 --- a/tests/test_submissions.py +++ b/tests/test_submissions.py @@ -4,16 +4,38 @@ from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import DeliveryStatus -from tests.conftest import create_delivery +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, + db: AsyncSession, + client: AsyncClient, ): - delivery = await create_delivery(db, DeliveryStatus.PENDING) + _, 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}") @@ -29,10 +51,22 @@ async def test_get_submission_returns_submission_with_payload( @pytest.mark.asyncio async def test_get_submission_returns_deliveries( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - delivery = await create_delivery(db, DeliveryStatus.PENDING) + _, 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}") @@ -53,10 +87,21 @@ async def test_get_submission_returns_deliveries( @pytest.mark.asyncio async def test_get_submission_returns_delivery_state( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): - delivery = await create_delivery(db, DeliveryStatus.AWAITING_RETRY) + _, 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" @@ -79,18 +124,316 @@ async def test_get_submission_returns_delivery_state( @pytest.mark.asyncio -async def test_get_submission_not_found( - client: AsyncClient, -): +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, -): +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_workspaces.py b/tests/test_workspaces.py new file mode 100644 index 0000000..ae2b2e6 --- /dev/null +++ b/tests/test_workspaces.py @@ -0,0 +1,269 @@ +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.services.workspace import ( + WorkspaceAccessDeniedError, + get_user_workspace, + list_user_workspaces, +) + + +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 From c8565237a9fa144645558bb96dffe7a3d898103e Mon Sep 17 00:00:00 2001 From: loppify Date: Thu, 24 Sep 2026 12:33:58 +0200 Subject: [PATCH 3/4] 23-auth-boundary closed. also closed 24 enforce workspace isolation --- app/api/deliveries.py | 13 ++- app/api/forms.py | 13 ++- app/database/queries/deliveries.py | 16 ++-- app/database/queries/workspace.py | 10 ++ app/services/delivery.py | 13 +-- app/services/forms.py | 26 +++-- tests/test_deliveries.py | 93 +++++++++--------- tests/test_forms.py | 146 +++++++++++++++++++++++++++++ tests/test_replay.py | 39 ++++---- 9 files changed, 280 insertions(+), 89 deletions(-) diff --git a/app/api/deliveries.py b/app/api/deliveries.py index 6ea893d..3d8aa67 100644 --- a/app/api/deliveries.py +++ b/app/api/deliveries.py @@ -17,9 +17,9 @@ @router.get("/{delivery_id}", response_model=DeliveryDetailRead) async def get_detailed_delivery( - delivery_id: int, - db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user) + delivery_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), ): res = await get_accessible_delivery_by_id(db, delivery_id, user_id=user.id) @@ -37,8 +37,11 @@ async def get_detailed_delivery( response_model=DeliveryDetailRead, status_code=status.HTTP_202_ACCEPTED, ) -async def replay_delivery(delivery_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user)): +async def replay_delivery( + delivery_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): try: delivery = await queue_manual_replay(db, delivery_id, user.id) except DeliveryNotFoundError: diff --git a/app/api/forms.py b/app/api/forms.py index c6e8e02..cd0f6f6 100644 --- a/app/api/forms.py +++ b/app/api/forms.py @@ -15,7 +15,7 @@ from app.database.session import get_db from app.schemas.forms import FormRead from app.schemas.submissions import SubmissionRead -from app.services.forms import FormCreate, create_form +from app.services.forms import FormCreate, WorkspaceNotFoundError, create_form router = APIRouter(prefix="/api/forms", tags=["forms"]) @@ -25,11 +25,20 @@ 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[SubmissionRead]) diff --git a/app/database/queries/deliveries.py b/app/database/queries/deliveries.py index 7790801..cc1fb15 100644 --- a/app/database/queries/deliveries.py +++ b/app/database/queries/deliveries.py @@ -4,24 +4,26 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import Delivery, Submission, Form, WorkspaceMembership +from app.database.models import Delivery, Form, Submission, WorkspaceMembership -async def get_accessible_delivery_by_id(db: AsyncSession, delivery_id: int, user_id: uuid.UUID): +async def get_accessible_delivery_by_id( + db: AsyncSession, delivery_id: int, user_id: uuid.UUID +): return await db.scalar( select(Delivery) .join(Submission, Delivery.submission_id == Submission.id) .join(Form, Form.id == Submission.form_id) - .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Form.workspace_id) + .join( + WorkspaceMembership, WorkspaceMembership.workspace_id == Form.workspace_id + ) .options(selectinload(Delivery.attempts)) - .where(Delivery.id == delivery_id, - WorkspaceMembership.user_id == user_id) - + .where(Delivery.id == delivery_id, WorkspaceMembership.user_id == user_id) ) async def get_delivery_for_processing( - db: AsyncSession, delivery_id: int + db: AsyncSession, delivery_id: int ) -> Delivery | None: result = await db.execute( select(Delivery) diff --git a/app/database/queries/workspace.py b/app/database/queries/workspace.py index 8ba0b0c..463c88b 100644 --- a/app/database/queries/workspace.py +++ b/app/database/queries/workspace.py @@ -29,3 +29,13 @@ async def get_user_workspaces(db: AsyncSession, user_id: uuid.UUID) -> list[Work .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) + ) diff --git a/app/services/delivery.py b/app/services/delivery.py index 049cfb6..cbd85c0 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,9 +17,11 @@ Destination, FailureType, Form, - Submission, ) -from app.database.queries.deliveries import get_accessible_delivery_by_id, get_delivery_for_processing +from app.database.queries.deliveries import ( + get_accessible_delivery_by_id, + get_delivery_for_processing, +) from app.services.telegram import format_submission_message, send_telegram_alert MAX_DELIVERY_ATTEMPTS = 5 @@ -81,7 +82,7 @@ async def execute_delivery_attempt(destination: Destination, message: str): async def finish_delivery_attempt( - db: AsyncSession, delivery: Delivery, delivery_attempt: DeliveryAttempt, res: dict + db: AsyncSession, delivery: Delivery, delivery_attempt: DeliveryAttempt, res: dict ) -> None: log = logger.bind( delivery_id=delivery.id, @@ -281,8 +282,8 @@ async def queue_manual_replay(db: AsyncSession, delivery_id, user_id) -> Deliver if delivery is None: raise DeliveryNotFoundError if delivery.status not in ( - DeliveryStatus.FAILED, - DeliveryStatus.UNKNOWN, + DeliveryStatus.FAILED, + DeliveryStatus.UNKNOWN, ): raise DeliveryNotReplayableError diff --git a/app/services/forms.py b/app/services/forms.py index e7c6037..a6910a4 100644 --- a/app/services/forms.py +++ b/app/services/forms.py @@ -1,10 +1,11 @@ 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 class DestinationType(str, enum.Enum): @@ -20,13 +21,26 @@ class FormCreate(BaseModel): title: str language: str = "en" destinations: list[DestinationCreate] + workspace_id: uuid.UUID + + +class WorkspaceNotFoundError(Exception): + pass 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/tests/test_deliveries.py b/tests/test_deliveries.py index 8a19d46..4e06839 100644 --- a/tests/test_deliveries.py +++ b/tests/test_deliveries.py @@ -16,18 +16,29 @@ Destination, FailureType, Form, - Submission, WorkspaceRole, + Submission, + WorkspaceRole, ) from app.database.queries.deliveries import get_accessible_delivery_by_id from app.services.delivery import ( MAX_DELIVERY_ATTEMPTS, + DeliveryNotFoundError, claim_next_delivery, finish_delivery_attempt, - process_delivery, DeliveryNotFoundError, queue_manual_replay, + process_delivery, + queue_manual_replay, ) from app.workers.delivery import recovery_stale_deliveries -from tests.conftest import create_delivery, create_form, create_submission, create_user, create_workspace, \ - create_membership, register_and_get_user, create_authenticated_workspace +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 @@ -75,7 +86,7 @@ async def test_delivery_creation(db: AsyncSession): @pytest.mark.asyncio async def test_new_delivery_starts_pending_without_attempts( - db: AsyncSession, + db: AsyncSession, ): form = await create_form( db, @@ -156,7 +167,7 @@ async def test_duplicate_delivery_failure(db: AsyncSession): @pytest.mark.asyncio async def test_delivery_states_are_independent( - db: AsyncSession, + db: AsyncSession, ): form = await create_form( db, @@ -238,8 +249,8 @@ async def test_delivery_states_are_independent( ], ) async def test_delivery_status_is_persisted( - db: AsyncSession, - status: DeliveryStatus, + db: AsyncSession, + status: DeliveryStatus, ): delivery = await create_delivery( db, @@ -254,7 +265,7 @@ async def test_delivery_status_is_persisted( @pytest.mark.asyncio async def test_awaiting_retry_delivery_can_be_selected_when_due( - db: AsyncSession, + db: AsyncSession, ): delivery = await create_delivery( db, @@ -279,7 +290,7 @@ async def test_awaiting_retry_delivery_can_be_selected_when_due( @pytest.mark.asyncio async def test_awaiting_retry_delivery_is_not_selected_before_due( - db: AsyncSession, + db: AsyncSession, ): delivery = await create_delivery( db, @@ -304,7 +315,7 @@ async def test_awaiting_retry_delivery_is_not_selected_before_due( @pytest.mark.asyncio async def test_retryable_failure_schedules_retry( - db: AsyncSession, + db: AsyncSession, ): delivery = await create_delivery( db, @@ -346,7 +357,7 @@ async def test_retryable_failure_schedules_retry( @pytest.mark.asyncio async def test_retry_delay_increases_with_attempt_count( - db: AsyncSession, + db: AsyncSession, ): delivery = await create_delivery( db, @@ -382,7 +393,7 @@ async def test_retry_delay_increases_with_attempt_count( @pytest.mark.asyncio async def test_retryable_failure_exhausts_retries( - db: AsyncSession, + db: AsyncSession, ): delivery = await create_delivery( db, @@ -445,7 +456,7 @@ async def fake_execute_delivery_attempt(destination, message): @pytest.mark.asyncio async def test_unexpected_exception_respects_max_attempts( - db: AsyncSession, monkeypatch + db: AsyncSession, monkeypatch ): async def fake_execute_delivery_attempt(destination, message): raise RuntimeError("Test Failure") @@ -706,7 +717,7 @@ async def test_get_delivery_returns_attempts(db: AsyncSession, client): assert response.status_code == 200 assert data["attempt_count"] == 1 assert ( - data["attempts"][0]["result"] == DeliveryAttemptResult.RETRYABLE_FAILURE.value + data["attempts"][0]["result"] == DeliveryAttemptResult.RETRYABLE_FAILURE.value ) @@ -723,7 +734,7 @@ async def test_get_delivery_returns_404_when_not_found(client, db: AsyncSession) async def test_get_accessible_delivery_by_id_returns_delivery_for_member( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) workspace = await create_workspace(db) @@ -770,7 +781,7 @@ async def test_get_accessible_delivery_by_id_returns_delivery_for_member( async def test_get_accessible_delivery_by_id_returns_none_for_foreign_workspace( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) @@ -816,7 +827,7 @@ async def test_get_accessible_delivery_by_id_returns_none_for_foreign_workspace( async def test_get_accessible_delivery_by_id_returns_none_for_unknown_delivery( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) @@ -830,8 +841,8 @@ async def test_get_accessible_delivery_by_id_returns_none_for_unknown_delivery( async def test_get_delivery_returns_accessible_delivery( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): user = await register_and_get_user(client, db) @@ -865,9 +876,7 @@ async def test_get_delivery_returns_accessible_delivery( await db.commit() - response = await client.get( - f"/api/deliveries/{delivery.id}" - ) + response = await client.get(f"/api/deliveries/{delivery.id}") assert response.status_code == 200 assert response.json()["id"] == delivery.id @@ -875,8 +884,8 @@ async def test_get_delivery_returns_accessible_delivery( @pytest.mark.asyncio async def test_get_delivery_hides_foreign_workspace_delivery( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): await create_authenticated_workspace(client, db) @@ -886,17 +895,15 @@ async def test_get_delivery_hides_foreign_workspace_delivery( workspace=foreign_workspace, ) - response = await client.get( - f"/api/deliveries/{delivery.id}" - ) + response = await client.get(f"/api/deliveries/{delivery.id}") assert response.status_code == 404 @pytest.mark.asyncio async def test_get_delivery_requires_authentication( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): workspace = await create_workspace(db) delivery = await create_delivery( @@ -906,15 +913,13 @@ async def test_get_delivery_requires_authentication( client.cookies.clear() - response = await client.get( - f"/api/deliveries/{delivery.id}" - ) + response = await client.get(f"/api/deliveries/{delivery.id}") assert response.status_code == 401 async def test_manual_replay_allows_workspace_member( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) workspace = await create_workspace(db) @@ -967,7 +972,7 @@ async def test_manual_replay_allows_workspace_member( async def test_manual_replay_rejects_foreign_workspace_delivery( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) @@ -1014,7 +1019,7 @@ async def test_manual_replay_rejects_foreign_workspace_delivery( async def test_rejected_foreign_replay_preserves_delivery( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) @@ -1065,8 +1070,8 @@ async def test_rejected_foreign_replay_preserves_delivery( async def test_replay_foreign_delivery_returns_404( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): await register_and_get_user(client, db) @@ -1105,16 +1110,14 @@ async def test_replay_foreign_delivery_returns_404( await db.commit() - response = await client.post( - f"/api/deliveries/{delivery.id}/replay" - ) + response = await client.post(f"/api/deliveries/{delivery.id}/replay") assert response.status_code == 404 async def test_replay_requires_authentication( - db: AsyncSession, - client: AsyncClient, + db: AsyncSession, + client: AsyncClient, ): workspace = await create_workspace(db) @@ -1147,8 +1150,6 @@ async def test_replay_requires_authentication( client.cookies.clear() - response = await client.post( - f"/api/deliveries/{delivery.id}/replay" - ) + response = await client.post(f"/api/deliveries/{delivery.id}/replay") assert response.status_code == 401 diff --git a/tests/test_forms.py b/tests/test_forms.py index e85cefb..c8427c9 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -3,6 +3,7 @@ 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 @@ -965,3 +966,148 @@ async def test_get_form_submissions_requires_authentication( 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 index f59918f..016512a 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -8,21 +8,28 @@ DeliveryAttempt, DeliveryAttemptResult, DeliveryStatus, - DeliveryTrigger, WorkspaceRole, + DeliveryTrigger, + WorkspaceRole, ) from app.services.delivery import ( DeliveryNotFoundError, DeliveryNotReplayableError, claim_next_delivery, - process_delivery, queue_manual_replay, + process_delivery, + queue_manual_replay, +) +from tests.conftest import ( + create_authenticated_workspace, + create_delivery, + create_membership, + create_user, + create_workspace, ) -from tests.conftest import create_delivery, create_user, create_workspace, create_membership, \ - create_authenticated_workspace @pytest.mark.asyncio async def test_failed_delivery_can_be_queued_for_manual_replay( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) workspace = await create_workspace(db) @@ -64,7 +71,7 @@ async def test_failed_delivery_can_be_queued_for_manual_replay( @pytest.mark.asyncio async def test_unknown_delivery_can_be_queued_for_manual_replay( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) workspace = await create_workspace(db) @@ -97,7 +104,7 @@ async def test_unknown_delivery_can_be_queued_for_manual_replay( @pytest.mark.asyncio async def test_manual_replay_raises_not_found_for_missing_delivery( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) @@ -120,8 +127,8 @@ async def test_manual_replay_raises_not_found_for_missing_delivery( ], ) async def test_manual_replay_rejects_non_replayable_status( - db: AsyncSession, - status: DeliveryStatus, + db: AsyncSession, + status: DeliveryStatus, ): user = await create_user(db) workspace = await create_workspace(db) @@ -174,9 +181,9 @@ async def test_replay_missing_delivery_returns_404(client, db: AsyncSession): @pytest.mark.asyncio async def test_manual_replay_is_recorded_in_attempt_history( - client, - db: AsyncSession, - monkeypatch, + client, + db: AsyncSession, + monkeypatch, ): user, workspace = await create_authenticated_workspace(client, db) @@ -204,9 +211,7 @@ async def fake_execute_delivery_attempt(destination, message): await process_delivery(delivery.id, db) attempt = await db.scalar( - select(DeliveryAttempt).where( - DeliveryAttempt.delivery_id == delivery.id - ) + select(DeliveryAttempt).where(DeliveryAttempt.delivery_id == delivery.id) ) assert attempt.trigger == DeliveryTrigger.MANUAL_REPLAY @@ -215,7 +220,7 @@ async def fake_execute_delivery_attempt(destination, message): @pytest.mark.asyncio async def test_manual_replay_rejects_delivery_from_inaccessible_workspace( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) @@ -245,7 +250,7 @@ async def test_manual_replay_rejects_delivery_from_inaccessible_workspace( @pytest.mark.asyncio async def test_manual_replay_membership_in_other_workspace_does_not_grant_access( - db: AsyncSession, + db: AsyncSession, ): user = await create_user(db) From 9395fff1d287a32c0a25e52653b2d7eea6c4a3d0 Mon Sep 17 00:00:00 2001 From: loppify Date: Fri, 25 Sep 2026 07:05:51 +0200 Subject: [PATCH 4/4] 24 enforce workspace isolation closed --- app/api/forms.py | 3 +- app/database/queries/workspace.py | 29 ++++++- app/exceptions.py | 24 ++++++ app/services/auth.py | 9 +- app/services/delivery.py | 7 +- app/services/forms.py | 7 +- app/services/workspace.py | 5 +- tests/test_workspaces.py | 136 +++++++++++++++++++++++++++++- 8 files changed, 194 insertions(+), 26 deletions(-) create mode 100644 app/exceptions.py diff --git a/app/api/forms.py b/app/api/forms.py index cd0f6f6..946473c 100644 --- a/app/api/forms.py +++ b/app/api/forms.py @@ -13,9 +13,10 @@ 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 SubmissionRead -from app.services.forms import FormCreate, WorkspaceNotFoundError, create_form +from app.services.forms import FormCreate, create_form router = APIRouter(prefix="/api/forms", tags=["forms"]) diff --git a/app/database/queries/workspace.py b/app/database/queries/workspace.py index 463c88b..f02325c 100644 --- a/app/database/queries/workspace.py +++ b/app/database/queries/workspace.py @@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import Workspace, WorkspaceMembership +from app.database.models import Workspace, WorkspaceMembership, WorkspaceRole +from app.exceptions import WorkspaceNotFoundError, WorkspacePermissionDeniedError async def get_workspace_membership_by_user_and_workspace_id( @@ -39,3 +40,29 @@ async def get_accessible_workspace_by_id( .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/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/services/auth.py b/app/services/auth.py index acd08b7..0574e37 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -12,6 +12,7 @@ get_session_by_token_hash, get_user_by_email, ) +from app.exceptions import InvalidCredentialsError, UserAlreadyExistsError password_hasher = PasswordHasher() SESSION_TTL = 30 @@ -55,10 +56,6 @@ async def create_session(db: AsyncSession, user: User) -> str: return token -class UserAlreadyExistsError(Exception): - pass - - def normalize_email(email: str) -> str: return email.strip().lower() @@ -85,10 +82,6 @@ async def get_user_by_session_token(db: AsyncSession, token: str) -> User | None return user_session.user -class InvalidCredentialsError(Exception): - pass - - async def login_user(db, email, password) -> tuple[User, str]: normalized_email = normalize_email(email) user = await get_user_by_email(db, normalized_email) diff --git a/app/services/delivery.py b/app/services/delivery.py index cbd85c0..ef3d89e 100644 --- a/app/services/delivery.py +++ b/app/services/delivery.py @@ -22,6 +22,7 @@ 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 @@ -270,12 +271,6 @@ async def process_delivery(delivery_id: int, db: AsyncSession) -> None: raise -class DeliveryNotFoundError(Exception): ... - - -class DeliveryNotReplayableError(Exception): ... - - async def queue_manual_replay(db: AsyncSession, delivery_id, user_id) -> Delivery: delivery = await get_accessible_delivery_by_id(db, delivery_id, user_id) diff --git a/app/services/forms.py b/app/services/forms.py index a6910a4..957b4f6 100644 --- a/app/services/forms.py +++ b/app/services/forms.py @@ -6,6 +6,7 @@ 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): @@ -24,10 +25,6 @@ class FormCreate(BaseModel): workspace_id: uuid.UUID -class WorkspaceNotFoundError(Exception): - pass - - async def create_form( db: AsyncSession, title: str, @@ -38,7 +35,7 @@ async def create_form( ) -> Form: workspace = await get_accessible_workspace_by_id(db, user_id, workspace_id) if workspace is None: - raise WorkspaceNotFoundError() + raise WorkspaceNotFoundError form = Form(title=title, language=language, workspace=workspace) diff --git a/app/services/workspace.py b/app/services/workspace.py index a9f4467..9ad57a0 100644 --- a/app/services/workspace.py +++ b/app/services/workspace.py @@ -7,10 +7,7 @@ get_user_workspaces, get_workspace_membership_by_user_and_workspace_id, ) - - -class WorkspaceAccessDeniedError(Exception): - pass +from app.exceptions import WorkspaceAccessDeniedError async def get_user_workspace( diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py index ae2b2e6..6c232da 100644 --- a/tests/test_workspaces.py +++ b/tests/test_workspaces.py @@ -3,11 +3,20 @@ from app.database.models import User, Workspace, WorkspaceMembership, WorkspaceRole from app.database.queries.auth import get_user_by_email -from app.services.workspace import ( +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( @@ -267,3 +276,128 @@ async def test_list_workspaces_does_not_expose_other_users_workspace( 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}, + )