From 7dc688537c48a15b00307f8ef0ecd29780b6aa28 Mon Sep 17 00:00:00 2001 From: Kayvan Shah <59110083+KayvanShah1@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:00:57 -0700 Subject: [PATCH 1/4] test: add user update tests --- backend/app/api/v1/users.py | 87 +++++++++++++++++++++++++++++++++++-- backend/app/crud/user.py | 30 ++++++++++++- backend/app/models/user.py | 2 + backend/app/schemas/user.py | 14 +++++- backend/tests/test_users.py | 38 ++++++++++++++++ 5 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_users.py diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 8f11f63..0f24775 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,9 +1,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.dependencies import get_db +from app.core.dependencies import get_current_user, get_db, verify_api_key from app.crud import user as crud_user -from app.schemas.user import UserCreate, UserOut +from app.models.user import User +from app.schemas.user import UserCreate, UserOut, UserUpdate router = APIRouter() @@ -17,6 +18,84 @@ async def signup(user_in: UserCreate, db: AsyncSession = Depends(get_db)): existing = await crud_user.get_user_by_username(db, user_in.username) if existing: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken") - user = await crud_user.create_user(db, user_in.username, user_in.password) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken" + ) + if user_in.email: + email_existing = await crud_user.get_user_by_email(db, user_in.email) + if email_existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already taken" + ) + user = await crud_user.create_user( + db, + user_in.username, + user_in.password, + email=user_in.email, + display_name=user_in.display_name, + ) return user + + +@router.get( + "/users/me", + response_model=UserOut, + dependencies=[Depends(verify_api_key)], + status_code=status.HTTP_200_OK, +) +async def get_me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.put( + "/users/me", + response_model=UserOut, + dependencies=[Depends(verify_api_key)], + status_code=status.HTTP_200_OK, +) +async def update_me( + update: UserUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + data = update.model_dump(exclude_unset=True) + if "username" in data: + existing = await crud_user.get_user_by_username(db, data["username"]) + if existing and existing.id != current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken" + ) + if "email" in data: + existing = await crud_user.get_user_by_email(db, data["email"]) + if existing and existing.id != current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already taken" + ) + return await crud_user.update_user(db, current_user, data) + + +@router.patch( + "/users/me", + response_model=UserOut, + dependencies=[Depends(verify_api_key)], + status_code=status.HTTP_200_OK, +) +async def patch_me( + update: UserUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + data = update.model_dump(exclude_unset=True) + if "username" in data: + existing = await crud_user.get_user_by_username(db, data["username"]) + if existing and existing.id != current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken" + ) + if "email" in data: + existing = await crud_user.get_user_by_email(db, data["email"]) + if existing and existing.id != current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already taken" + ) + return await crud_user.update_user(db, current_user, data) diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index ac5ae34..f1294eb 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -12,10 +12,36 @@ async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User return result.scalars().first() -async def create_user(db: AsyncSession, username: str, password: str): +async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: + result = await db.execute(select(User).filter(User.email == email)) + return result.scalars().first() + + +async def create_user( + db: AsyncSession, + username: str, + password: str, + *, + email: str | None = None, + display_name: str | None = None, +): hashed_pw = hash_password(password) - new_user = User(username=username, hashed_password=hashed_pw) + new_user = User( + username=username, + hashed_password=hashed_pw, + email=email, + display_name=display_name, + ) db.add(new_user) await db.commit() await db.refresh(new_user) return new_user + + +async def update_user(db: AsyncSession, user: User, updates: dict) -> User: + for field, value in updates.items(): + setattr(user, field, value) + db.add(user) + await db.commit() + await db.refresh(user) + return user diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 054db61..4517581 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -8,6 +8,8 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True, nullable=False) + email = Column(String, unique=True, index=True, nullable=True) + display_name = Column(String, nullable=True) hashed_password = Column(String, nullable=False) tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 697fdf7..3d6efd0 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,9 +1,13 @@ -from pydantic import BaseModel, ConfigDict +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr class UserCreate(BaseModel): username: str password: str + email: Optional[EmailStr] = None + display_name: Optional[str] = None model_config = ConfigDict( json_schema_extra={ @@ -20,5 +24,13 @@ class UserCreate(BaseModel): class UserOut(BaseModel): id: int username: str + email: Optional[EmailStr] = None + display_name: Optional[str] = None model_config = {"from_attributes": True} + + +class UserUpdate(BaseModel): + username: Optional[str] = None + email: Optional[EmailStr] = None + display_name: Optional[str] = None diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..c77a966 --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,38 @@ +import os + +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture +async def auth_headers(async_client): + API_KEY = os.getenv("TSKZ_HTTP_API_KEY", "123456") + username = "user1" + password = "secret" + await async_client.post( + "/signup", json={"username": username, "password": password} + ) + token_res = await async_client.post( + "/token", data={"username": username, "password": password} + ) + token = token_res.json()["access_token"] + return {"Authorization": f"Bearer {token}", "X-API-Key": API_KEY} + + +@pytest.mark.asyncio +async def test_get_and_update_user(async_client, auth_headers): + res = await async_client.get("/users/me", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["username"] == "user1" + + update = {"email": "user1@example.com", "display_name": "User One"} + res = await async_client.put("/users/me", json=update, headers=auth_headers) + assert res.status_code == 200 + data = res.json() + assert data["email"] == "user1@example.com" + assert data["display_name"] == "User One" + + patch_data = {"display_name": "User 1"} + res = await async_client.patch("/users/me", json=patch_data, headers=auth_headers) + assert res.status_code == 200 + assert res.json()["display_name"] == "User 1" From 3c58232c235035160419911f2a9b7906e4ce8a22 Mon Sep 17 00:00:00 2001 From: Kayvan Shah <59110083+KayvanShah1@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:09:19 -0700 Subject: [PATCH 2/4] feat: implement email verification endpoints --- backend/app/api/v1/auth.py | 41 +++++++++++++++++++++ backend/app/crud/user.py | 31 ++++++++++++++++ backend/app/main.py | 3 +- backend/app/models/user.py | 5 ++- backend/app/schemas/auth.py | 9 +++++ backend/app/schemas/user.py | 1 + backend/tests/test_email_verification.py | 45 ++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/tests/test_email_verification.py diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..7660a34 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta +import secrets + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.dependencies import get_current_user, get_db, verify_api_key +from app.crud import user as crud_user +from app.models.user import User +from app.schemas.auth import Message, VerificationToken + +router = APIRouter() + + +@router.post( + "/auth/request-verification", + response_model=Message, + dependencies=[Depends(verify_api_key)], +) +async def request_verification( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if current_user.email_verified: + return {"detail": "Email already verified"} + token = secrets.token_urlsafe(16) + expires = datetime.utcnow() + timedelta(hours=1) + await crud_user.set_verification_token(db, current_user, token, expires) + # In a real app, send the token via email + return {"detail": token} + + +@router.post("/auth/verify", response_model=Message) +async def verify_email(token_in: VerificationToken, db: AsyncSession = Depends(get_db)): + user = await crud_user.verify_user_email(db, token_in.token) + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired token", + ) + return {"detail": "Email verified"} diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index f1294eb..3b74a53 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -45,3 +46,33 @@ async def update_user(db: AsyncSession, user: User, updates: dict) -> User: await db.commit() await db.refresh(user) return user + + +async def set_verification_token( + db: AsyncSession, user: User, token: str, expires: datetime +) -> User: + user.verification_token = token + user.verification_token_expires = expires + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +async def verify_user_email(db: AsyncSession, token: str) -> Optional[User]: + result = await db.execute(select(User).filter(User.verification_token == token)) + user = result.scalars().first() + if not user: + return None + if ( + not user.verification_token_expires + or user.verification_token_expires < datetime.utcnow() + ): + return None + user.email_verified = True + user.verification_token = None + user.verification_token_expires = None + db.add(user) + await db.commit() + await db.refresh(user) + return user diff --git a/backend/app/main.py b/backend/app/main.py index cb64035..eaab8e4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse -from app.api.v1 import login, tasks, users +from app.api.v1 import auth, login, tasks, users from app.core import metadata from app.core.config import settings from app.db.session import Base, engine @@ -43,6 +43,7 @@ async def lifespan(app: FastAPI): app.include_router(users.router, tags=["Users"]) app.include_router(login.router, tags=["Login"]) app.include_router(tasks.router, tags=["Tasks"]) +app.include_router(auth.router, tags=["Auth"]) @app.get("/", include_in_schema=False) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4517581..5875a70 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String +from sqlalchemy import Boolean, Column, DateTime, Integer, String from sqlalchemy.orm import relationship from app.db.session import Base @@ -11,5 +11,8 @@ class User(Base): email = Column(String, unique=True, index=True, nullable=True) display_name = Column(String, nullable=True) hashed_password = Column(String, nullable=False) + email_verified = Column(Boolean, default=False, nullable=False) + verification_token = Column(String, unique=True, index=True, nullable=True) + verification_token_expires = Column(DateTime, nullable=True) tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..98d3f1b --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class VerificationToken(BaseModel): + token: str + + +class Message(BaseModel): + detail: str diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3d6efd0..739f0c9 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -26,6 +26,7 @@ class UserOut(BaseModel): username: str email: Optional[EmailStr] = None display_name: Optional[str] = None + email_verified: bool = False model_config = {"from_attributes": True} diff --git a/backend/tests/test_email_verification.py b/backend/tests/test_email_verification.py new file mode 100644 index 0000000..1810369 --- /dev/null +++ b/backend/tests/test_email_verification.py @@ -0,0 +1,45 @@ +import os + +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture +async def auth_headers(async_client): + API_KEY = os.getenv("TSKZ_HTTP_API_KEY", "123456") + username = "verifyuser" + password = "secret" + await async_client.post( + "/signup", + json={ + "username": username, + "password": password, + "email": "verify@example.com", + }, + ) + token_res = await async_client.post( + "/token", data={"username": username, "password": password} + ) + token = token_res.json()["access_token"] + return {"Authorization": f"Bearer {token}", "X-API-Key": API_KEY} + + +@pytest.mark.asyncio +async def test_email_verification_flow(async_client, auth_headers): + # Initially unverified + res = await async_client.get("/users/me", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["email_verified"] is False + + # Request verification token + res = await async_client.post("/auth/request-verification", headers=auth_headers) + assert res.status_code == 200 + token = res.json()["detail"] + + # Verify using token + res = await async_client.post("/auth/verify", json={"token": token}) + assert res.status_code == 200 + + # Email should now be verified + res = await async_client.get("/users/me", headers=auth_headers) + assert res.json()["email_verified"] is True From a36225de6bfee13b6bdc67e277e236c4213093cf Mon Sep 17 00:00:00 2001 From: Kayvan Shah <59110083+KayvanShah1@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:21:10 -0700 Subject: [PATCH 3/4] Add per-user API key management --- backend/app/api/v1/apikeys.py | 41 +++++++++++++++++++++ backend/app/core/dependencies.py | 35 +++++++++++------- backend/app/crud/apikey.py | 46 ++++++++++++++++++++++++ backend/app/main.py | 3 +- backend/app/models/apikey.py | 20 +++++++++++ backend/app/models/user.py | 3 ++ backend/app/schemas/apikey.py | 16 +++++++++ backend/tests/test_api_keys.py | 43 ++++++++++++++++++++++ backend/tests/test_email_verification.py | 11 +++--- backend/tests/test_integration.py | 19 +++++----- backend/tests/test_invalid_auth.py | 27 +++++++++----- backend/tests/test_tasks.py | 13 ++++--- backend/tests/test_users.py | 11 +++--- 13 files changed, 242 insertions(+), 46 deletions(-) create mode 100644 backend/app/api/v1/apikeys.py create mode 100644 backend/app/crud/apikey.py create mode 100644 backend/app/models/apikey.py create mode 100644 backend/app/schemas/apikey.py create mode 100644 backend/tests/test_api_keys.py diff --git a/backend/app/api/v1/apikeys.py b/backend/app/api/v1/apikeys.py new file mode 100644 index 0000000..75bb6db --- /dev/null +++ b/backend/app/api/v1/apikeys.py @@ -0,0 +1,41 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.dependencies import get_current_user, get_db +from app.crud import apikey as crud_apikey +from app.models.user import User +from app.schemas.apikey import APIKeyCreated, APIKeyOut + +router = APIRouter(prefix="/apikeys", tags=["API Keys"]) + + +@router.post("", response_model=APIKeyCreated, status_code=status.HTTP_201_CREATED) +async def create_api_key( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + key, db_key = await crud_apikey.create_api_key(db, current_user) + return APIKeyCreated(id=db_key.id, prefix=db_key.prefix, created_at=db_key.created_at, revoked=db_key.revoked, key=key) + + +@router.get("", response_model=List[APIKeyOut]) +async def list_api_keys( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + keys = await crud_apikey.list_api_keys(db, current_user) + return keys + + +@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_api_key( + api_key_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + success = await crud_apikey.revoke_api_key(db, current_user, api_key_id) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found") + return None diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 9f3bf1a..dd96626 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import verify_access_token -from app.core.config import settings +from app.crud import apikey as crud_apikey from app.crud.user import get_user_by_username from app.db.session import async_session from app.models.user import User @@ -22,17 +22,6 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: yield session -# ---------------------------- # -# Dependency: API Key Check -# ---------------------------- # -def verify_api_key(x_api_key: str = Depends(api_key_header)): - if not x_api_key: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API Key header missing") - if x_api_key != settings.HTTP_API_KEY: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API Key") - return x_api_key - - # ---------------------------- # # Dependency: Get Current User # ---------------------------- # @@ -45,3 +34,25 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession detail="User not found", ) return user + + +# ---------------------------- # +# Dependency: API Key Check +# ---------------------------- # +async def verify_api_key( + x_api_key: str = Depends(api_key_header), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if not x_api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="API Key header missing", + ) + api_key = await crud_apikey.verify_api_key(db, current_user, x_api_key) + if not api_key: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid API Key", + ) + return api_key diff --git a/backend/app/crud/apikey.py b/backend/app/crud/apikey.py new file mode 100644 index 0000000..0bffd9e --- /dev/null +++ b/backend/app/crud/apikey.py @@ -0,0 +1,46 @@ +from datetime import datetime +from secrets import token_urlsafe +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import hash_password, verify_password +from app.models.apikey import APIKey +from app.models.user import User + + +async def create_api_key(db: AsyncSession, user: User) -> tuple[str, APIKey]: + key = token_urlsafe(32) + hashed = hash_password(key) + db_key = APIKey(user_id=user.id, hashed_key=hashed, prefix=key[:8]) + db.add(db_key) + await db.commit() + await db.refresh(db_key) + return key, db_key + + +async def list_api_keys(db: AsyncSession, user: User) -> List[APIKey]: + result = await db.execute(select(APIKey).where(APIKey.user_id == user.id)) + return result.scalars().all() + + +async def verify_api_key(db: AsyncSession, user: User, raw_key: str) -> Optional[APIKey]: + result = await db.execute( + select(APIKey).where(APIKey.user_id == user.id, APIKey.revoked.is_(False)) + ) + for api_key in result.scalars().all(): + if verify_password(raw_key, api_key.hashed_key): + api_key.last_used_at = datetime.utcnow() + await db.commit() + return api_key + return None + + +async def revoke_api_key(db: AsyncSession, user: User, api_key_id: int) -> bool: + api_key = await db.get(APIKey, api_key_id) + if not api_key or api_key.user_id != user.id: + return False + api_key.revoked = True + await db.commit() + return True diff --git a/backend/app/main.py b/backend/app/main.py index eaab8e4..c88db78 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse -from app.api.v1 import auth, login, tasks, users +from app.api.v1 import apikeys, auth, login, tasks, users from app.core import metadata from app.core.config import settings from app.db.session import Base, engine @@ -43,6 +43,7 @@ async def lifespan(app: FastAPI): app.include_router(users.router, tags=["Users"]) app.include_router(login.router, tags=["Login"]) app.include_router(tasks.router, tags=["Tasks"]) +app.include_router(apikeys.router, tags=["API Keys"]) app.include_router(auth.router, tags=["Auth"]) diff --git a/backend/app/models/apikey.py b/backend/app/models/apikey.py new file mode 100644 index 0000000..493f4af --- /dev/null +++ b/backend/app/models/apikey.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class APIKey(Base): + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + hashed_key = Column(String, unique=True, nullable=False, index=True) + prefix = Column(String, nullable=False) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + revoked = Column(Boolean, default=False, nullable=False) + last_used_at = Column(DateTime, nullable=True) + + user = relationship("User", back_populates="api_keys") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5875a70..4ef89f1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,3 +16,6 @@ class User(Base): verification_token_expires = Column(DateTime, nullable=True) tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan") + api_keys = relationship( + "APIKey", back_populates="user", cascade="all, delete-orphan" + ) diff --git a/backend/app/schemas/apikey.py b/backend/app/schemas/apikey.py new file mode 100644 index 0000000..b740248 --- /dev/null +++ b/backend/app/schemas/apikey.py @@ -0,0 +1,16 @@ +from datetime import datetime +from pydantic import BaseModel + + +class APIKeyOut(BaseModel): + id: int + prefix: str + created_at: datetime + revoked: bool + + class Config: + orm_mode = True + + +class APIKeyCreated(APIKeyOut): + key: str diff --git a/backend/tests/test_api_keys.py b/backend/tests/test_api_keys.py new file mode 100644 index 0000000..6c62ac4 --- /dev/null +++ b/backend/tests/test_api_keys.py @@ -0,0 +1,43 @@ +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture +async def token_and_headers(async_client): + await async_client.post("/signup", json={"username": "keyuser", "password": "pass"}) + token_res = await async_client.post( + "/token", data={"username": "keyuser", "password": "pass"} + ) + token = token_res.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + return token, headers + + +@pytest.mark.asyncio +async def test_api_key_lifecycle(async_client, token_and_headers): + token, headers = token_and_headers + + create_res = await async_client.post("/apikeys", headers=headers) + assert create_res.status_code == 201 + data = create_res.json() + key = data["key"] + api_key_id = data["id"] + + list_res = await async_client.get("/apikeys", headers=headers) + assert list_res.status_code == 200 + listed = list_res.json()[0] + assert listed["id"] == api_key_id + assert listed["prefix"] == key[:8] + assert listed["revoked"] is False + + # revoke + del_res = await async_client.delete(f"/apikeys/{api_key_id}", headers=headers) + assert del_res.status_code == 204 + + list_res = await async_client.get("/apikeys", headers=headers) + assert list_res.json()[0]["revoked"] is True + + # attempt to use revoked key + headers_with_key = {**headers, "X-API-Key": key} + res = await async_client.get("/tasks/", headers=headers_with_key) + assert res.status_code == 403 diff --git a/backend/tests/test_email_verification.py b/backend/tests/test_email_verification.py index 1810369..0243c4e 100644 --- a/backend/tests/test_email_verification.py +++ b/backend/tests/test_email_verification.py @@ -1,12 +1,9 @@ -import os - import pytest import pytest_asyncio @pytest_asyncio.fixture async def auth_headers(async_client): - API_KEY = os.getenv("TSKZ_HTTP_API_KEY", "123456") username = "verifyuser" password = "secret" await async_client.post( @@ -21,7 +18,13 @@ async def auth_headers(async_client): "/token", data={"username": username, "password": password} ) token = token_res.json()["access_token"] - return {"Authorization": f"Bearer {token}", "X-API-Key": API_KEY} + + key_res = await async_client.post( + "/apikeys", headers={"Authorization": f"Bearer {token}"} + ) + api_key = key_res.json()["key"] + + return {"Authorization": f"Bearer {token}", "X-API-Key": api_key} @pytest.mark.asyncio diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py index d55bd14..7771f5c 100644 --- a/backend/tests/test_integration.py +++ b/backend/tests/test_integration.py @@ -1,10 +1,5 @@ -import os - -import dotenv import pytest -dotenv.load_dotenv() - @pytest.mark.asyncio async def test_full_flow(async_client): @@ -16,7 +11,11 @@ async def test_full_flow(async_client): res = await async_client.post("/token", data={"username": "alice", "password": "alicepw"}) assert res.status_code == 200 token = res.json()["access_token"] - headers = {"Authorization": f"Bearer {token}", "X-API-Key": "123456"} + key_res = await async_client.post( + "/apikeys", headers={"Authorization": f"Bearer {token}"} + ) + api_key = key_res.json()["key"] + headers = {"Authorization": f"Bearer {token}", "X-API-Key": api_key} # 3. Create multiple tasks for i in range(3): @@ -48,13 +47,15 @@ async def test_full_flow(async_client): @pytest.mark.asyncio async def test_task_crud_lifecycle(async_client): - API_KEY = os.getenv("TSKZ_HTTP_API_KEY", "sample_key") - # Setup user await async_client.post("/signup", json={"username": "john", "password": "pass"}) token_res = await async_client.post("/token", data={"username": "john", "password": "pass"}) token = token_res.json()["access_token"] - headers = {"Authorization": f"Bearer {token}", "X-API-Key": API_KEY} + key_res = await async_client.post( + "/apikeys", headers={"Authorization": f"Bearer {token}"} + ) + api_key = key_res.json()["key"] + headers = {"Authorization": f"Bearer {token}", "X-API-Key": api_key} # Create task task_data = {"title": "Task 1", "description": "Test it", "status": "pending"} diff --git a/backend/tests/test_invalid_auth.py b/backend/tests/test_invalid_auth.py index d972959..026efbb 100644 --- a/backend/tests/test_invalid_auth.py +++ b/backend/tests/test_invalid_auth.py @@ -1,10 +1,6 @@ -import os - import pytest -from dotenv import load_dotenv +import pytest_asyncio -load_dotenv() -VALID_API_KEY = os.getenv("TSKZ_HTTP_API_KEY") INVALID_API_KEY = "invalid-key" BAD_TOKEN = "Bearer badtoken.fake.jwt" @@ -22,10 +18,23 @@ def requires_body(method: str) -> bool: return method in {"post", "put", "patch"} +@pytest_asyncio.fixture +async def valid_api_key(async_client): + await async_client.post("/signup", json={"username": "apiuser", "password": "pass"}) + token_res = await async_client.post( + "/token", data={"username": "apiuser", "password": "pass"} + ) + token = token_res.json()["access_token"] + key_res = await async_client.post( + "/apikeys", headers={"Authorization": f"Bearer {token}"} + ) + return key_res.json()["key"] + + @pytest.mark.parametrize("method,endpoint", TASK_ENDPOINTS) @pytest.mark.asyncio -async def test_missing_jwt_token(async_client, method, endpoint): - headers = {"X-API-Key": VALID_API_KEY} +async def test_missing_jwt_token(async_client, valid_api_key, method, endpoint): + headers = {"X-API-Key": valid_api_key} kwargs = {"headers": headers} if requires_body(method): kwargs["json"] = {} @@ -47,8 +56,8 @@ async def test_missing_api_key(async_client, auth_headers_only_token, method, en @pytest.mark.parametrize("method,endpoint", TASK_ENDPOINTS) @pytest.mark.asyncio -async def test_invalid_jwt_token(async_client, method, endpoint): - headers = {"Authorization": BAD_TOKEN, "X-API-Key": VALID_API_KEY} +async def test_invalid_jwt_token(async_client, valid_api_key, method, endpoint): + headers = {"Authorization": BAD_TOKEN, "X-API-Key": valid_api_key} kwargs = {"headers": headers} if requires_body(method): kwargs["json"] = {} diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index a098692..70c49e7 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -1,15 +1,9 @@ -import os - import pytest import pytest_asyncio -from dotenv import load_dotenv - -load_dotenv() @pytest_asyncio.fixture async def auth_headers(async_client): - API_KEY = os.getenv("TSKZ_HTTP_API_KEY", "sample_key") username = "john" password = "pass" @@ -17,7 +11,12 @@ async def auth_headers(async_client): token_res = await async_client.post("/token", data={"username": username, "password": password}) token = token_res.json()["access_token"] - return {"Authorization": f"Bearer {token}", "X-API-Key": API_KEY} + key_res = await async_client.post( + "/apikeys", headers={"Authorization": f"Bearer {token}"} + ) + api_key = key_res.json()["key"] + + return {"Authorization": f"Bearer {token}", "X-API-Key": api_key} @pytest_asyncio.fixture diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py index c77a966..a41dcec 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -1,12 +1,9 @@ -import os - import pytest import pytest_asyncio @pytest_asyncio.fixture async def auth_headers(async_client): - API_KEY = os.getenv("TSKZ_HTTP_API_KEY", "123456") username = "user1" password = "secret" await async_client.post( @@ -16,7 +13,13 @@ async def auth_headers(async_client): "/token", data={"username": username, "password": password} ) token = token_res.json()["access_token"] - return {"Authorization": f"Bearer {token}", "X-API-Key": API_KEY} + + key_res = await async_client.post( + "/apikeys", headers={"Authorization": f"Bearer {token}"} + ) + api_key = key_res.json()["key"] + + return {"Authorization": f"Bearer {token}", "X-API-Key": api_key} @pytest.mark.asyncio From b6deb4b731bf17ad96d03de09ff060a4da022c0c Mon Sep 17 00:00:00 2001 From: Kayvan Shah <59110083+KayvanShah1@users.noreply.github.com> Date: Thu, 28 Aug 2025 20:52:47 -0700 Subject: [PATCH 4/4] Support task queries and bulk operations --- backend/app/api/v1/tasks.py | 48 ++++++++++++++++++++++++++++++-- backend/app/crud/task.py | 55 +++++++++++++++++++++++++++++++++++-- backend/app/schemas/task.py | 14 ++++++++++ backend/tests/test_tasks.py | 46 +++++++++++++++++++++++++++---- 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index 15f8d4b..58eb97b 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -1,10 +1,19 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.dependencies import get_current_user, get_db, verify_api_key from app.crud import task as crud from app.models.user import User -from app.schemas.task import TaskCreate, TaskOut, TaskStatusUpdate, TaskUpdate +from app.schemas.task import ( + TaskBulkRequest, + TaskBulkResponse, + TaskCreate, + TaskOut, + TaskStatus, + TaskStatusBulkUpdate, + TaskStatusUpdate, + TaskUpdate, +) router = APIRouter(prefix="/tasks", tags=["Tasks"], dependencies=[Depends(verify_api_key)]) @@ -20,10 +29,23 @@ async def create_task( @router.get("/", response_model=list[TaskOut], status_code=status.HTTP_200_OK) async def list_tasks( + status: TaskStatus | None = None, + q: str | None = None, + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + sort: str = Query("desc", pattern="^(asc|desc)$"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - return await crud.get_tasks_for_user(db, user.id) + return await crud.get_tasks_for_user( + db, + user.id, + status=status, + q=q, + page=page, + limit=limit, + sort=sort, + ) @router.get("/{task_id}", response_model=TaskOut, status_code=status.HTTP_200_OK) @@ -74,3 +96,23 @@ async def delete_task( if not task: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") await crud.delete_task(db, task) + + +@router.post("/bulk", response_model=TaskBulkResponse, status_code=status.HTTP_200_OK) +async def bulk_tasks( + payload: TaskBulkRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + created = [] + if payload.create: + created = await crud.create_tasks_bulk( + db, user.id, [task.model_dump() for task in payload.create] + ) + + updated = [] + if payload.update_status: + updates = [(u.id, u.status) for u in payload.update_status] + updated = await crud.update_tasks_status_bulk(db, user.id, updates) + + return TaskBulkResponse(created=created, updated=updated) diff --git a/backend/app/crud/task.py b/backend/app/crud/task.py index 17f8dda..f17bf57 100644 --- a/backend/app/crud/task.py +++ b/backend/app/crud/task.py @@ -1,7 +1,11 @@ -from app.models.task import Task +from typing import Iterable + +from sqlalchemy import asc, desc from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from app.models.task import Task, TaskStatus + async def create_task(db: AsyncSession, user_id: int, task_data: dict) -> Task: task = Task(**task_data, user_id=user_id) @@ -11,8 +15,25 @@ async def create_task(db: AsyncSession, user_id: int, task_data: dict) -> Task: return task -async def get_tasks_for_user(db: AsyncSession, user_id: int): - result = await db.execute(select(Task).where(Task.user_id == user_id)) +async def get_tasks_for_user( + db: AsyncSession, + user_id: int, + *, + status: TaskStatus | None = None, + q: str | None = None, + page: int = 1, + limit: int = 20, + sort: str = "desc", +): + stmt = select(Task).where(Task.user_id == user_id) + if status: + stmt = stmt.where(Task.status == status) + if q: + stmt = stmt.where(Task.title.ilike(f"%{q}%")) + + order = desc(Task.created_at) if sort.lower() == "desc" else asc(Task.created_at) + stmt = stmt.order_by(order).offset((page - 1) * limit).limit(limit) + result = await db.execute(stmt) return result.scalars().all() @@ -40,3 +61,31 @@ async def update_task_status(db: AsyncSession, task: Task, new_status: str): async def delete_task(db: AsyncSession, task: Task): await db.delete(task) await db.commit() + + +async def create_tasks_bulk(db: AsyncSession, user_id: int, tasks_data: Iterable[dict]): + tasks = [Task(**data, user_id=user_id) for data in tasks_data] + db.add_all(tasks) + await db.commit() + for task in tasks: + await db.refresh(task) + return tasks + + +async def update_tasks_status_bulk( + db: AsyncSession, user_id: int, updates: Iterable[tuple[int, TaskStatus]] +): + tasks: list[Task] = [] + for task_id, status in updates: + result = await db.execute( + select(Task).where(Task.id == task_id, Task.user_id == user_id) + ) + task = result.scalar_one_or_none() + if task: + task.status = status + tasks.append(task) + + await db.commit() + for task in tasks: + await db.refresh(task) + return tasks diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 9082ed9..921913f 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -63,3 +63,17 @@ class TaskOut(TaskBase): created_at: datetime model_config = {"from_attributes": True} + + +class TaskStatusBulkUpdate(TaskStatusUpdate): + id: int + + +class TaskBulkRequest(BaseModel): + create: list[TaskCreate] = [] + update_status: list[TaskStatusBulkUpdate] = [] + + +class TaskBulkResponse(BaseModel): + created: list[TaskOut] = [] + updated: list[TaskOut] = [] diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 70c49e7..d3dfdc4 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -38,15 +38,28 @@ async def test_create_task(async_client, auth_headers): @pytest.mark.asyncio async def test_list_tasks(async_client, auth_headers): - # Create 2 tasks - for i in range(2): + # Create tasks with varying titles and statuses + for i in range(6): + status = "completed" if i % 2 == 0 else "pending" await async_client.post( - "/tasks/", json={"title": f"Task {i}", "description": "Sample", "status": "pending"}, headers=auth_headers + "/tasks/", + json={"title": f"Task {i}", "description": "Sample", "status": status}, + headers=auth_headers, ) - res = await async_client.get("/tasks/", headers=auth_headers) + # Filter by status + res = await async_client.get("/tasks/?status=completed", headers=auth_headers) assert res.status_code == 200 - assert len(res.json()) >= 2 + assert all(t["status"] == "completed" for t in res.json()) + + # Search by title + res = await async_client.get("/tasks/?q=Task 1", headers=auth_headers) + assert len(res.json()) == 1 + assert res.json()[0]["title"] == "Task 1" + + # Pagination + res = await async_client.get("/tasks/?limit=2&page=2&sort=asc", headers=auth_headers) + assert len(res.json()) == 2 @pytest.mark.asyncio @@ -84,3 +97,26 @@ async def test_delete_task(async_client, auth_headers, created_task): # Ensure task is gone res = await async_client.get(f"/tasks/{task_id}", headers=auth_headers) assert res.status_code == 404 + + +@pytest.mark.asyncio +async def test_bulk_create_and_update(async_client, auth_headers): + payload = { + "create": [ + {"title": "Bulk 1", "description": "", "status": "pending"}, + {"title": "Bulk 2", "description": "", "status": "pending"}, + ] + } + res = await async_client.post("/tasks/bulk", json=payload, headers=auth_headers) + assert res.status_code == 200 + created = res.json()["created"] + assert len(created) == 2 + + update_payload = { + "update_status": [{"id": created[0]["id"], "status": "completed"}] + } + res = await async_client.post("/tasks/bulk", json=update_payload, headers=auth_headers) + assert res.status_code == 200 + updated = res.json()["updated"] + assert len(updated) == 1 + assert updated[0]["status"] == "completed"