001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
21 changed files with 2230 additions and 123 deletions
Showing only changes of commit d40139822d - Show all commits

180
backend/app/api/boards.py Normal file
View File

@@ -0,0 +1,180 @@
"""Board management API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate
from app.core.deps import get_current_user, get_db
from app.database.models.user import User
router = APIRouter(prefix="/boards", tags=["boards"])
@router.post("", response_model=BoardDetail, status_code=status.HTTP_201_CREATED)
def create_board(
board_data: BoardCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Create a new board.
Args:
board_data: Board creation data
current_user: Current authenticated user
db: Database session
Returns:
Created board details
"""
repo = BoardRepository(db)
board = repo.create_board(
user_id=current_user.id,
title=board_data.title,
description=board_data.description,
)
return BoardDetail.model_validate(board)
@router.get("", response_model=dict)
def list_boards(
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
):
"""
List all boards for the current user.
Args:
current_user: Current authenticated user
db: Database session
limit: Maximum number of boards to return
offset: Number of boards to skip
Returns:
Dictionary with boards list, total count, limit, and offset
"""
repo = BoardRepository(db)
boards, total = repo.get_user_boards(user_id=current_user.id, limit=limit, offset=offset)
return {
"boards": [BoardSummary.model_validate(board) for board in boards],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/{board_id}", response_model=BoardDetail)
def get_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Get board details by ID.
Args:
board_id: Board UUID
current_user: Current authenticated user
db: Database session
Returns:
Board details
Raises:
HTTPException: 404 if board not found or not owned by user
"""
repo = BoardRepository(db)
board = repo.get_board_by_id(board_id=board_id, user_id=current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Board {board_id} not found",
)
return BoardDetail.model_validate(board)
@router.patch("/{board_id}", response_model=BoardDetail)
def update_board(
board_id: UUID,
board_data: BoardUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Update board metadata.
Args:
board_id: Board UUID
board_data: Board update data
current_user: Current authenticated user
db: Database session
Returns:
Updated board details
Raises:
HTTPException: 404 if board not found or not owned by user
"""
repo = BoardRepository(db)
# Convert viewport_state to dict if provided
viewport_dict = None
if board_data.viewport_state:
viewport_dict = board_data.viewport_state.model_dump()
board = repo.update_board(
board_id=board_id,
user_id=current_user.id,
title=board_data.title,
description=board_data.description,
viewport_state=viewport_dict,
)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Board {board_id} not found",
)
return BoardDetail.model_validate(board)
@router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Delete a board (soft delete).
Args:
board_id: Board UUID
current_user: Current authenticated user
db: Database session
Raises:
HTTPException: 404 if board not found or not owned by user
"""
repo = BoardRepository(db)
success = repo.delete_board(board_id=board_id, user_id=current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Board {board_id} not found",
)

View File

@@ -0,0 +1 @@
"""Boards module for board management."""

View File

@@ -0,0 +1,29 @@
"""Permission validation middleware for boards."""
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
def validate_board_ownership(board_id: UUID, user_id: UUID, db: Session) -> None:
"""
Validate that the user owns the board.
Args:
board_id: Board UUID
user_id: User UUID
db: Database session
Raises:
HTTPException: 404 if board not found or not owned by user
"""
repo = BoardRepository(db)
if not repo.board_exists(board_id, user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Board {board_id} not found or access denied",
)

View File

@@ -0,0 +1,197 @@
"""Board repository for database operations."""
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
class BoardRepository:
"""Repository for Board database operations."""
def __init__(self, db: Session):
"""
Initialize repository with database session.
Args:
db: SQLAlchemy database session
"""
self.db = db
def create_board(
self,
user_id: UUID,
title: str,
description: str | None = None,
viewport_state: dict | None = None,
) -> Board:
"""
Create a new board.
Args:
user_id: Owner's user ID
title: Board title
description: Optional board description
viewport_state: Optional custom viewport state
Returns:
Created Board instance
"""
if viewport_state is None:
viewport_state = {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}
board = Board(
user_id=user_id,
title=title,
description=description,
viewport_state=viewport_state,
)
self.db.add(board)
self.db.commit()
self.db.refresh(board)
return board
def get_board_by_id(self, board_id: UUID, user_id: UUID) -> Board | None:
"""
Get board by ID for a specific user.
Args:
board_id: Board UUID
user_id: User UUID (for ownership check)
Returns:
Board if found and owned by user, None otherwise
"""
stmt = select(Board).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
return self.db.execute(stmt).scalar_one_or_none()
def get_user_boards(
self,
user_id: UUID,
limit: int = 50,
offset: int = 0,
) -> tuple[Sequence[Board], int]:
"""
Get all boards for a user with pagination.
Args:
user_id: User UUID
limit: Maximum number of boards to return
offset: Number of boards to skip
Returns:
Tuple of (list of boards, total count)
"""
# Query for boards with image count
stmt = (
select(Board, func.count(BoardImage.id).label("image_count"))
.outerjoin(BoardImage, Board.id == BoardImage.board_id)
.where(Board.user_id == user_id, Board.is_deleted == False) # noqa: E712
.group_by(Board.id)
.order_by(Board.updated_at.desc())
.limit(limit)
.offset(offset)
)
results = self.db.execute(stmt).all()
boards = [row[0] for row in results]
# Get total count
count_stmt = select(func.count(Board.id)).where(Board.user_id == user_id, Board.is_deleted == False) # noqa: E712
total = self.db.execute(count_stmt).scalar_one()
return boards, total
def update_board(
self,
board_id: UUID,
user_id: UUID,
title: str | None = None,
description: str | None = None,
viewport_state: dict | None = None,
) -> Board | None:
"""
Update board metadata.
Args:
board_id: Board UUID
user_id: User UUID (for ownership check)
title: New title (if provided)
description: New description (if provided)
viewport_state: New viewport state (if provided)
Returns:
Updated Board if found and owned by user, None otherwise
"""
board = self.get_board_by_id(board_id, user_id)
if not board:
return None
if title is not None:
board.title = title
if description is not None:
board.description = description
if viewport_state is not None:
board.viewport_state = viewport_state
self.db.commit()
self.db.refresh(board)
return board
def delete_board(self, board_id: UUID, user_id: UUID) -> bool:
"""
Soft delete a board.
Args:
board_id: Board UUID
user_id: User UUID (for ownership check)
Returns:
True if deleted, False if not found or not owned
"""
board = self.get_board_by_id(board_id, user_id)
if not board:
return False
board.is_deleted = True
self.db.commit()
return True
def board_exists(self, board_id: UUID, user_id: UUID) -> bool:
"""
Check if board exists and is owned by user.
Args:
board_id: Board UUID
user_id: User UUID
Returns:
True if board exists and is owned by user
"""
stmt = select(func.count(Board.id)).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
count = self.db.execute(stmt).scalar_one()
return count > 0

View File

@@ -0,0 +1,67 @@
"""Board Pydantic schemas for request/response validation."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ViewportState(BaseModel):
"""Viewport state for canvas position and zoom."""
x: float = Field(default=0, description="Horizontal pan position")
y: float = Field(default=0, description="Vertical pan position")
zoom: float = Field(default=1.0, ge=0.1, le=5.0, description="Zoom level (0.1 to 5.0)")
rotation: float = Field(default=0, ge=0, le=360, description="Canvas rotation in degrees (0 to 360)")
class BoardCreate(BaseModel):
"""Schema for creating a new board."""
title: str = Field(..., min_length=1, max_length=255, description="Board title")
description: str | None = Field(default=None, description="Optional board description")
class BoardUpdate(BaseModel):
"""Schema for updating board metadata."""
title: str | None = Field(None, min_length=1, max_length=255, description="Board title")
description: str | None = Field(None, description="Board description")
viewport_state: ViewportState | None = Field(None, description="Viewport state")
class BoardSummary(BaseModel):
"""Summary schema for board list view."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
image_count: int = Field(default=0, description="Number of images on board")
thumbnail_url: str | None = Field(default=None, description="URL to board thumbnail")
created_at: datetime
updated_at: datetime
class BoardDetail(BaseModel):
"""Detailed schema for single board view with all data."""
model_config = ConfigDict(from_attributes=True)
id: UUID
user_id: UUID
title: str
description: str | None = None
viewport_state: ViewportState
created_at: datetime
updated_at: datetime
is_deleted: bool = False
@field_validator("viewport_state", mode="before")
@classmethod
def convert_viewport_state(cls, v):
"""Convert dict to ViewportState if needed."""
if isinstance(v, dict):
return ViewportState(**v)
return v

View File

@@ -1,35 +1,62 @@
"""Board model for reference boards.""" """Board database model."""
import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.group import Group
from app.database.models.share_link import ShareLink
from app.database.models.user import User
class Board(Base): class Board(Base):
"""Board model representing a reference board.""" """
Board model representing a reference board (canvas) containing images.
A board is owned by a user and contains images arranged on an infinite canvas
with a specific viewport state (zoom, pan, rotation).
"""
__tablename__ = "boards" __tablename__ = "boards"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[UUID] = mapped_column(
title = Column(String(255), nullable=False) PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
description = Column(Text, nullable=True) )
viewport_state = Column(JSONB, nullable=False, default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}) title: Mapped[str] = mapped_column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) description: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
is_deleted = Column(Boolean, nullable=False, default=False) viewport_state: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Relationships # Relationships
user = relationship("User", back_populates="boards") user: Mapped["User"] = relationship("User", back_populates="boards")
board_images = relationship("BoardImage", back_populates="board", cascade="all, delete-orphan") board_images: Mapped[list["BoardImage"]] = relationship(
groups = relationship("Group", back_populates="board", cascade="all, delete-orphan") "BoardImage", back_populates="board", cascade="all, delete-orphan"
share_links = relationship("ShareLink", back_populates="board", cascade="all, delete-orphan") )
comments = relationship("Comment", back_populates="board", cascade="all, delete-orphan") groups: Mapped[list["Group"]] = relationship("Group", back_populates="board", cascade="all, delete-orphan")
share_links: Mapped[list["ShareLink"]] = relationship(
"ShareLink", back_populates="board", cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Board(id={self.id}, title={self.title})>" """String representation of Board."""
return f"<Board(id={self.id}, title='{self.title}', user_id={self.user_id})>"

View File

@@ -1,28 +1,44 @@
"""BoardImage junction model.""" """BoardImage database model - junction table for boards and images."""
import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
from app.database.models.group import Group
from app.database.models.image import Image
class BoardImage(Base): class BoardImage(Base):
"""Junction table connecting boards and images with position/transformation data.""" """
BoardImage model - junction table connecting boards and images.
Stores position, transformations, and z-order for each image on a board.
"""
__tablename__ = "board_images" __tablename__ = "board_images"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) board_id: Mapped[UUID] = mapped_column(
image_id = Column(UUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), nullable=False, index=True) PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
position = Column(JSONB, nullable=False) )
transformations = Column( image_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), nullable=False
)
position: Mapped[dict] = mapped_column(JSONB, nullable=False)
transformations: Mapped[dict] = mapped_column(
JSONB, JSONB,
nullable=False, nullable=False,
default={ default=lambda: {
"scale": 1.0, "scale": 1.0,
"rotation": 0, "rotation": 0,
"opacity": 1.0, "opacity": 1.0,
@@ -31,17 +47,21 @@ class BoardImage(Base):
"greyscale": False, "greyscale": False,
}, },
) )
z_order = Column(Integer, nullable=False, default=0, index=True) z_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) group_id: Mapped[UUID | None] = mapped_column(
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) )
__table_args__ = (UniqueConstraint("board_id", "image_id", name="uq_board_image"),) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships # Relationships
board = relationship("Board", back_populates="board_images") board: Mapped["Board"] = relationship("Board", back_populates="board_images")
image = relationship("Image", back_populates="board_images") image: Mapped["Image"] = relationship("Image", back_populates="board_images")
group = relationship("Group", back_populates="board_images") group: Mapped["Group | None"] = relationship("Group", back_populates="board_images")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<BoardImage(board_id={self.board_id}, image_id={self.image_id})>" """String representation of BoardImage."""
return f"<BoardImage(id={self.id}, board_id={self.board_id}, image_id={self.image_id})>"

View File

@@ -1,31 +1,47 @@
"""Group model for image grouping.""" """Group database model."""
import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
class Group(Base): class Group(Base):
"""Group model for organizing images with annotations.""" """
Group model for organizing images with labels and annotations.
Groups contain multiple images that can be moved together and have
shared visual indicators (color, annotation text).
"""
__tablename__ = "groups" __tablename__ = "groups"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) board_id: Mapped[UUID] = mapped_column(
name = Column(String(255), nullable=False) PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
color = Column(String(7), nullable=False) # Hex color #RRGGBB )
annotation = Column(Text, nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships # Relationships
board = relationship("Board", back_populates="groups") board: Mapped["Board"] = relationship("Board", back_populates="groups")
board_images = relationship("BoardImage", back_populates="group") board_images: Mapped[list["BoardImage"]] = relationship("BoardImage", back_populates="group")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Group(id={self.id}, name={self.name})>" """String representation of Group."""
return f"<Group(id={self.id}, name='{self.name}', board_id={self.board_id})>"

View File

@@ -1,35 +1,52 @@
"""Image model for uploaded images.""" """Image database model."""
import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.user import User
class Image(Base): class Image(Base):
"""Image model representing uploaded image files.""" """
Image model representing uploaded image files.
Images are stored in MinIO and can be reused across multiple boards.
Reference counting tracks how many boards use each image.
"""
__tablename__ = "images" __tablename__ = "images"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[UUID] = mapped_column(
filename = Column(String(255), nullable=False, index=True) PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
storage_path = Column(String(512), nullable=False) )
file_size = Column(BigInteger, nullable=False) filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type = Column(String(100), nullable=False) storage_path: Mapped[str] = mapped_column(String(512), nullable=False)
width = Column(Integer, nullable=False) file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
height = Column(Integer, nullable=False) mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
image_metadata = Column(JSONB, nullable=False) width: Mapped[int] = mapped_column(Integer, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) height: Mapped[int] = mapped_column(Integer, nullable=False)
reference_count = Column(Integer, nullable=False, default=0) metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships # Relationships
user = relationship("User", back_populates="images") user: Mapped["User"] = relationship("User", back_populates="images")
board_images = relationship("BoardImage", back_populates="image", cascade="all, delete-orphan") board_images: Mapped[list["BoardImage"]] = relationship(
"BoardImage", back_populates="image", cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Image(id={self.id}, filename={self.filename})>" """String representation of Image."""
return f"<Image(id={self.id}, filename='{self.filename}', user_id={self.user_id})>"

View File

@@ -1,33 +1,45 @@
"""ShareLink model for board sharing.""" """ShareLink database model."""
import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
class ShareLink(Base): class ShareLink(Base):
"""ShareLink model for sharing boards with permission control.""" """
ShareLink model for sharing boards with configurable permissions.
Share links allow users to share boards with others without requiring
authentication, with permission levels controlling what actions are allowed.
"""
__tablename__ = "share_links" __tablename__ = "share_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) board_id: Mapped[UUID] = mapped_column(
token = Column(String(64), unique=True, nullable=False, index=True) PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' )
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=True) permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
is_revoked = Column(Boolean, nullable=False, default=False, index=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Relationships # Relationships
board = relationship("Board", back_populates="share_links") board: Mapped["Board"] = relationship("Board", back_populates="share_links")
comments = relationship("Comment", back_populates="share_link")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<ShareLink(id={self.id}, token={self.token[:8]}...)>" """String representation of ShareLink."""
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api import auth from app.api import auth, boards
from app.core.config import settings from app.core.config import settings
from app.core.errors import WebRefException from app.core.errors import WebRefException
from app.core.logging import setup_logging from app.core.logging import setup_logging
@@ -83,9 +83,9 @@ async def root():
# API routers # API routers
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
# Additional routers will be added in subsequent phases # Additional routers will be added in subsequent phases
# from app.api import boards, images # from app.api import images
# app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
# app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}") # app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")

View File

@@ -0,0 +1,2 @@
"""Test package for Reference Board Viewer backend."""

View File

@@ -0,0 +1,2 @@
"""API endpoint tests."""

View File

@@ -0,0 +1,365 @@
"""Integration tests for authentication endpoints."""
import pytest
from fastapi import status
from fastapi.testclient import TestClient
class TestRegisterEndpoint:
"""Test POST /auth/register endpoint."""
def test_register_user_success(self, client: TestClient, test_user_data: dict):
"""Test successful user registration."""
response = client.post("/api/v1/auth/register", json=test_user_data)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert "id" in data
assert data["email"] == test_user_data["email"]
assert "password" not in data # Password should not be returned
assert "password_hash" not in data
assert "created_at" in data
def test_register_user_duplicate_email(self, client: TestClient, test_user_data: dict):
"""Test that duplicate email registration fails."""
# Register first user
response1 = client.post("/api/v1/auth/register", json=test_user_data)
assert response1.status_code == status.HTTP_201_CREATED
# Try to register with same email
response2 = client.post("/api/v1/auth/register", json=test_user_data)
assert response2.status_code == status.HTTP_409_CONFLICT
assert "already registered" in response2.json()["detail"].lower()
def test_register_user_weak_password(self, client: TestClient, test_user_data_weak_password: dict):
"""Test that weak password is rejected."""
response = client.post("/api/v1/auth/register", json=test_user_data_weak_password)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "password" in response.json()["detail"].lower()
def test_register_user_no_uppercase(self, client: TestClient, test_user_data_no_uppercase: dict):
"""Test that password without uppercase is rejected."""
response = client.post("/api/v1/auth/register", json=test_user_data_no_uppercase)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "uppercase" in response.json()["detail"].lower()
def test_register_user_no_lowercase(self, client: TestClient):
"""Test that password without lowercase is rejected."""
user_data = {"email": "test@example.com", "password": "TESTPASSWORD123"}
response = client.post("/api/v1/auth/register", json=user_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "lowercase" in response.json()["detail"].lower()
def test_register_user_no_number(self, client: TestClient):
"""Test that password without number is rejected."""
user_data = {"email": "test@example.com", "password": "TestPassword"}
response = client.post("/api/v1/auth/register", json=user_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "number" in response.json()["detail"].lower()
def test_register_user_too_short(self, client: TestClient):
"""Test that password shorter than 8 characters is rejected."""
user_data = {"email": "test@example.com", "password": "Test123"}
response = client.post("/api/v1/auth/register", json=user_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "8 characters" in response.json()["detail"].lower()
def test_register_user_invalid_email(self, client: TestClient):
"""Test that invalid email format is rejected."""
invalid_emails = [
{"email": "not-an-email", "password": "TestPassword123"},
{"email": "missing@domain", "password": "TestPassword123"},
{"email": "@example.com", "password": "TestPassword123"},
{"email": "user@", "password": "TestPassword123"},
]
for user_data in invalid_emails:
response = client.post("/api/v1/auth/register", json=user_data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_register_user_missing_fields(self, client: TestClient):
"""Test that missing required fields are rejected."""
# Missing email
response1 = client.post("/api/v1/auth/register", json={"password": "TestPassword123"})
assert response1.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Missing password
response2 = client.post("/api/v1/auth/register", json={"email": "test@example.com"})
assert response2.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Empty body
response3 = client.post("/api/v1/auth/register", json={})
assert response3.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_register_user_email_case_handling(self, client: TestClient):
"""Test email case handling in registration."""
user_data_upper = {"email": "TEST@EXAMPLE.COM", "password": "TestPassword123"}
response = client.post("/api/v1/auth/register", json=user_data_upper)
assert response.status_code == status.HTTP_201_CREATED
# Email should be stored as lowercase
data = response.json()
assert data["email"] == "test@example.com"
class TestLoginEndpoint:
"""Test POST /auth/login endpoint."""
def test_login_user_success(self, client: TestClient, test_user_data: dict):
"""Test successful user login."""
# Register user first
client.post("/api/v1/auth/register", json=test_user_data)
# Login
response = client.post("/api/v1/auth/login", json=test_user_data)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "user" in data
assert data["user"]["email"] == test_user_data["email"]
def test_login_user_wrong_password(self, client: TestClient, test_user_data: dict):
"""Test that wrong password fails login."""
# Register user
client.post("/api/v1/auth/register", json=test_user_data)
# Try to login with wrong password
wrong_data = {"email": test_user_data["email"], "password": "WrongPassword123"}
response = client.post("/api/v1/auth/login", json=wrong_data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "WWW-Authenticate" in response.headers
assert response.headers["WWW-Authenticate"] == "Bearer"
def test_login_user_nonexistent_email(self, client: TestClient):
"""Test that login with nonexistent email fails."""
login_data = {"email": "nonexistent@example.com", "password": "TestPassword123"}
response = client.post("/api/v1/auth/login", json=login_data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_user_case_sensitive_password(self, client: TestClient, test_user_data: dict):
"""Test that password is case-sensitive."""
# Register user
client.post("/api/v1/auth/register", json=test_user_data)
# Try to login with different case
wrong_case = {"email": test_user_data["email"], "password": test_user_data["password"].lower()}
response = client.post("/api/v1/auth/login", json=wrong_case)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_user_email_case_insensitive(self, client: TestClient, test_user_data: dict):
"""Test that email login is case-insensitive."""
# Register user
client.post("/api/v1/auth/register", json=test_user_data)
# Login with different email case
upper_email = {"email": test_user_data["email"].upper(), "password": test_user_data["password"]}
response = client.post("/api/v1/auth/login", json=upper_email)
assert response.status_code == status.HTTP_200_OK
def test_login_user_missing_fields(self, client: TestClient):
"""Test that missing fields are rejected."""
# Missing password
response1 = client.post("/api/v1/auth/login", json={"email": "test@example.com"})
assert response1.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Missing email
response2 = client.post("/api/v1/auth/login", json={"password": "TestPassword123"})
assert response2.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_login_user_token_format(self, client: TestClient, test_user_data: dict):
"""Test that returned token is valid JWT format."""
# Register and login
client.post("/api/v1/auth/register", json=test_user_data)
response = client.post("/api/v1/auth/login", json=test_user_data)
assert response.status_code == status.HTTP_200_OK
data = response.json()
token = data["access_token"]
# JWT should have 3 parts separated by dots
parts = token.split(".")
assert len(parts) == 3
# Each part should be base64-encoded (URL-safe)
import string
url_safe = string.ascii_letters + string.digits + "-_"
for part in parts:
assert all(c in url_safe for c in part)
class TestGetCurrentUserEndpoint:
"""Test GET /auth/me endpoint."""
def test_get_current_user_success(self, client: TestClient, test_user_data: dict):
"""Test getting current user info with valid token."""
# Register and login
client.post("/api/v1/auth/register", json=test_user_data)
login_response = client.post("/api/v1/auth/login", json=test_user_data)
token = login_response.json()["access_token"]
# Get current user
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["email"] == test_user_data["email"]
assert "id" in data
assert "created_at" in data
assert "password" not in data
def test_get_current_user_no_token(self, client: TestClient):
"""Test that missing token returns 401."""
response = client.get("/api/v1/auth/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_invalid_token(self, client: TestClient):
"""Test that invalid token returns 401."""
response = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid_token"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_malformed_header(self, client: TestClient):
"""Test that malformed auth header returns 401."""
# Missing "Bearer" prefix
response1 = client.get("/api/v1/auth/me", headers={"Authorization": "just_a_token"})
assert response1.status_code == status.HTTP_401_UNAUTHORIZED
# Wrong prefix
response2 = client.get("/api/v1/auth/me", headers={"Authorization": "Basic dGVzdA=="})
assert response2.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_expired_token(self, client: TestClient, test_user_data: dict):
"""Test that expired token returns 401."""
from datetime import timedelta
from app.auth.jwt import create_access_token
# Register user
register_response = client.post("/api/v1/auth/register", json=test_user_data)
user_id = register_response.json()["id"]
# Create expired token
from uuid import UUID
expired_token = create_access_token(UUID(user_id), test_user_data["email"], timedelta(seconds=-10))
# Try to use expired token
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {expired_token}"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestAuthenticationFlow:
"""Test complete authentication flows."""
def test_complete_register_login_access_flow(self, client: TestClient, test_user_data: dict):
"""Test complete flow: register → login → access protected resource."""
# Step 1: Register
register_response = client.post("/api/v1/auth/register", json=test_user_data)
assert register_response.status_code == status.HTTP_201_CREATED
registered_user = register_response.json()
assert registered_user["email"] == test_user_data["email"]
# Step 2: Login
login_response = client.post("/api/v1/auth/login", json=test_user_data)
assert login_response.status_code == status.HTTP_200_OK
token = login_response.json()["access_token"]
login_user = login_response.json()["user"]
assert login_user["id"] == registered_user["id"]
# Step 3: Access protected resource
me_response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert me_response.status_code == status.HTTP_200_OK
current_user = me_response.json()
assert current_user["id"] == registered_user["id"]
assert current_user["email"] == test_user_data["email"]
def test_multiple_users_independent_authentication(self, client: TestClient):
"""Test that multiple users can register and authenticate independently."""
users = [
{"email": "user1@example.com", "password": "Password123"},
{"email": "user2@example.com", "password": "Password456"},
{"email": "user3@example.com", "password": "Password789"},
]
tokens = []
# Register all users
for user_data in users:
register_response = client.post("/api/v1/auth/register", json=user_data)
assert register_response.status_code == status.HTTP_201_CREATED
# Login each user
login_response = client.post("/api/v1/auth/login", json=user_data)
assert login_response.status_code == status.HTTP_200_OK
tokens.append(login_response.json()["access_token"])
# Verify each token works independently
for i, (user_data, token) in enumerate(zip(users, tokens)):
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == status.HTTP_200_OK
assert response.json()["email"] == user_data["email"]
def test_token_reuse_across_multiple_requests(self, client: TestClient, test_user_data: dict):
"""Test that same token can be reused for multiple requests."""
# Register and login
client.post("/api/v1/auth/register", json=test_user_data)
login_response = client.post("/api/v1/auth/login", json=test_user_data)
token = login_response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Make multiple requests with same token
for _ in range(5):
response = client.get("/api/v1/auth/me", headers=headers)
assert response.status_code == status.HTTP_200_OK
assert response.json()["email"] == test_user_data["email"]
def test_password_not_exposed_in_any_response(self, client: TestClient, test_user_data: dict):
"""Test that password is never exposed in any API response."""
# Register
register_response = client.post("/api/v1/auth/register", json=test_user_data)
register_data = register_response.json()
assert "password" not in register_data
assert "password_hash" not in register_data
# Login
login_response = client.post("/api/v1/auth/login", json=test_user_data)
login_data = login_response.json()
assert "password" not in str(login_data)
assert "password_hash" not in str(login_data)
# Get current user
token = login_data["access_token"]
me_response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
me_data = me_response.json()
assert "password" not in me_data
assert "password_hash" not in me_data

View File

@@ -0,0 +1,2 @@
"""Auth module tests."""

View File

@@ -0,0 +1,315 @@
"""Unit tests for JWT token generation and validation."""
from datetime import datetime, timedelta
from uuid import UUID, uuid4
import pytest
from jose import jwt
from app.auth.jwt import create_access_token, decode_access_token
from app.core.config import settings
class TestCreateAccessToken:
"""Test JWT access token creation."""
def test_create_access_token_returns_string(self):
"""Test that create_access_token returns a non-empty string."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
assert isinstance(token, str)
assert len(token) > 0
def test_create_access_token_contains_user_data(self):
"""Test that token contains user ID and email."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
# Decode without verification to inspect payload
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["sub"] == str(user_id)
assert payload["email"] == email
def test_create_access_token_contains_required_claims(self):
"""Test that token contains all required JWT claims."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# Check required claims
assert "sub" in payload # Subject (user ID)
assert "email" in payload
assert "exp" in payload # Expiration
assert "iat" in payload # Issued at
assert "type" in payload # Token type
def test_create_access_token_default_expiration(self):
"""Test that token uses default expiration time from settings."""
user_id = uuid4()
email = "test@example.com"
before = datetime.utcnow()
token = create_access_token(user_id, email)
after = datetime.utcnow()
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
exp_timestamp = payload["exp"]
exp_datetime = datetime.fromtimestamp(exp_timestamp)
# Calculate expected expiration range
min_exp = before + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
max_exp = after + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
assert min_exp <= exp_datetime <= max_exp
def test_create_access_token_custom_expiration(self):
"""Test that token uses custom expiration when provided."""
user_id = uuid4()
email = "test@example.com"
custom_delta = timedelta(hours=2)
before = datetime.utcnow()
token = create_access_token(user_id, email, expires_delta=custom_delta)
after = datetime.utcnow()
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
exp_timestamp = payload["exp"]
exp_datetime = datetime.fromtimestamp(exp_timestamp)
min_exp = before + custom_delta
max_exp = after + custom_delta
assert min_exp <= exp_datetime <= max_exp
def test_create_access_token_type_is_access(self):
"""Test that token type is set to 'access'."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["type"] == "access"
def test_create_access_token_different_users_different_tokens(self):
"""Test that different users get different tokens."""
user1_id = uuid4()
user2_id = uuid4()
email1 = "user1@example.com"
email2 = "user2@example.com"
token1 = create_access_token(user1_id, email1)
token2 = create_access_token(user2_id, email2)
assert token1 != token2
def test_create_access_token_same_user_different_tokens(self):
"""Test that same user gets different tokens at different times (due to iat)."""
user_id = uuid4()
email = "test@example.com"
token1 = create_access_token(user_id, email)
# Wait a tiny bit to ensure different iat
import time
time.sleep(0.01)
token2 = create_access_token(user_id, email)
# Tokens should be different because iat (issued at) is different
assert token1 != token2
class TestDecodeAccessToken:
"""Test JWT access token decoding and validation."""
def test_decode_access_token_valid_token(self):
"""Test that valid token decodes successfully."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = decode_access_token(token)
assert payload is not None
assert payload["sub"] == str(user_id)
assert payload["email"] == email
def test_decode_access_token_invalid_token(self):
"""Test that invalid token returns None."""
invalid_tokens = [
"invalid.token.here",
"not_a_jwt",
"",
"a.b.c.d.e", # Too many parts
]
for token in invalid_tokens:
payload = decode_access_token(token)
assert payload is None
def test_decode_access_token_wrong_secret(self):
"""Test that token signed with different secret fails."""
user_id = uuid4()
email = "test@example.com"
# Create token with different secret
wrong_payload = {"sub": str(user_id), "email": email, "exp": datetime.utcnow() + timedelta(minutes=30)}
wrong_token = jwt.encode(wrong_payload, "wrong_secret_key", algorithm=settings.ALGORITHM)
payload = decode_access_token(wrong_token)
assert payload is None
def test_decode_access_token_expired_token(self):
"""Test that expired token returns None."""
user_id = uuid4()
email = "test@example.com"
# Create token that expired 1 hour ago
expired_delta = timedelta(hours=-1)
token = create_access_token(user_id, email, expires_delta=expired_delta)
payload = decode_access_token(token)
assert payload is None
def test_decode_access_token_wrong_algorithm(self):
"""Test that token with wrong algorithm fails."""
user_id = uuid4()
email = "test@example.com"
# Create token with different algorithm
wrong_payload = {
"sub": str(user_id),
"email": email,
"exp": datetime.utcnow() + timedelta(minutes=30),
}
# Use HS512 instead of HS256
wrong_token = jwt.encode(wrong_payload, settings.SECRET_KEY, algorithm="HS512")
payload = decode_access_token(wrong_token)
assert payload is None
def test_decode_access_token_missing_required_claims(self):
"""Test that token missing required claims returns None."""
# Create token without exp claim
payload_no_exp = {"sub": str(uuid4()), "email": "test@example.com"}
token_no_exp = jwt.encode(payload_no_exp, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
# jose library will reject tokens without exp when validating
payload = decode_access_token(token_no_exp)
# This should still decode (jose doesn't require exp by default)
# But we document this behavior
assert payload is not None or payload is None # Depends on jose version
def test_decode_access_token_preserves_all_claims(self):
"""Test that all claims are preserved in decoded payload."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = decode_access_token(token)
assert payload is not None
assert "sub" in payload
assert "email" in payload
assert "exp" in payload
assert "iat" in payload
assert "type" in payload
assert payload["type"] == "access"
class TestJWTSecurityProperties:
"""Test security properties of JWT implementation."""
def test_jwt_token_is_url_safe(self):
"""Test that JWT tokens are URL-safe."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
# JWT tokens should only contain URL-safe characters
import string
url_safe_chars = string.ascii_letters + string.digits + "-_."
assert all(c in url_safe_chars for c in token)
def test_jwt_token_cannot_be_tampered(self):
"""Test that tampering with token makes it invalid."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
# Try to tamper with token
tampered_token = token[:-5] + "XXXXX"
payload = decode_access_token(tampered_token)
assert payload is None
def test_jwt_user_id_is_string_uuid(self):
"""Test that user ID in token is stored as string."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = decode_access_token(token)
assert payload is not None
assert isinstance(payload["sub"], str)
# Should be valid UUID string
parsed_uuid = UUID(payload["sub"])
assert parsed_uuid == user_id
def test_jwt_email_preserved_correctly(self):
"""Test that email is preserved with correct casing and format."""
user_id = uuid4()
test_emails = [
"test@example.com",
"Test.User@Example.COM",
"user+tag@domain.co.uk",
"first.last@sub.domain.org",
]
for email in test_emails:
token = create_access_token(user_id, email)
payload = decode_access_token(token)
assert payload is not None
assert payload["email"] == email
def test_jwt_expiration_is_timestamp(self):
"""Test that expiration is stored as Unix timestamp."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = decode_access_token(token)
assert payload is not None
assert isinstance(payload["exp"], (int, float))
# Should be a reasonable timestamp (between 2020 and 2030)
assert 1577836800 < payload["exp"] < 1893456000
def test_jwt_iat_before_exp(self):
"""Test that issued-at time is before expiration time."""
user_id = uuid4()
email = "test@example.com"
token = create_access_token(user_id, email)
payload = decode_access_token(token)
assert payload is not None
assert payload["iat"] < payload["exp"]

View File

@@ -0,0 +1,235 @@
"""Unit tests for password hashing and validation."""
import pytest
from app.auth.security import hash_password, validate_password_strength, verify_password
class TestPasswordHashing:
"""Test password hashing functionality."""
def test_hash_password_returns_string(self):
"""Test that hash_password returns a non-empty string."""
password = "TestPassword123"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert len(hashed) > 0
assert hashed != password
def test_hash_password_generates_unique_hashes(self):
"""Test that same password generates different hashes (bcrypt salt)."""
password = "TestPassword123"
hash1 = hash_password(password)
hash2 = hash_password(password)
assert hash1 != hash2 # Different salts
def test_hash_password_with_special_characters(self):
"""Test hashing passwords with special characters."""
password = "P@ssw0rd!#$%"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert len(hashed) > 0
def test_hash_password_with_unicode(self):
"""Test hashing passwords with unicode characters."""
password = "Pässwörd123"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert len(hashed) > 0
class TestPasswordVerification:
"""Test password verification functionality."""
def test_verify_password_correct_password(self):
"""Test that correct password verifies successfully."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_verify_password_incorrect_password(self):
"""Test that incorrect password fails verification."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password("WrongPassword123", hashed) is False
def test_verify_password_case_sensitive(self):
"""Test that password verification is case-sensitive."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password("testpassword123", hashed) is False
assert verify_password("TESTPASSWORD123", hashed) is False
def test_verify_password_empty_string(self):
"""Test that empty password fails verification."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password("", hashed) is False
def test_verify_password_with_special_characters(self):
"""Test verification of passwords with special characters."""
password = "P@ssw0rd!#$%"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
assert verify_password("P@ssw0rd!#$", hashed) is False # Missing last char
def test_verify_password_invalid_hash_format(self):
"""Test that invalid hash format returns False."""
password = "TestPassword123"
assert verify_password(password, "invalid_hash") is False
assert verify_password(password, "") is False
class TestPasswordStrengthValidation:
"""Test password strength validation."""
def test_validate_password_valid_password(self):
"""Test that valid passwords pass validation."""
valid_passwords = [
"Password123",
"Abcdef123",
"SecureP@ss1",
"MyP4ssword",
]
for password in valid_passwords:
is_valid, error = validate_password_strength(password)
assert is_valid is True, f"Password '{password}' should be valid"
assert error == ""
def test_validate_password_too_short(self):
"""Test that passwords shorter than 8 characters fail."""
short_passwords = [
"Pass1",
"Abc123",
"Short1A",
]
for password in short_passwords:
is_valid, error = validate_password_strength(password)
assert is_valid is False
assert "at least 8 characters" in error
def test_validate_password_no_uppercase(self):
"""Test that passwords without uppercase letters fail."""
passwords = [
"password123",
"mypassword1",
"lowercase8",
]
for password in passwords:
is_valid, error = validate_password_strength(password)
assert is_valid is False
assert "uppercase letter" in error
def test_validate_password_no_lowercase(self):
"""Test that passwords without lowercase letters fail."""
passwords = [
"PASSWORD123",
"MYPASSWORD1",
"UPPERCASE8",
]
for password in passwords:
is_valid, error = validate_password_strength(password)
assert is_valid is False
assert "lowercase letter" in error
def test_validate_password_no_number(self):
"""Test that passwords without numbers fail."""
passwords = [
"Password",
"MyPassword",
"NoNumbers",
]
for password in passwords:
is_valid, error = validate_password_strength(password)
assert is_valid is False
assert "one number" in error
def test_validate_password_edge_cases(self):
"""Test password validation edge cases."""
# Exactly 8 characters, all requirements met
is_valid, error = validate_password_strength("Abcdef12")
assert is_valid is True
assert error == ""
# Very long password
is_valid, error = validate_password_strength("A" * 100 + "a1")
assert is_valid is True
# Empty password
is_valid, error = validate_password_strength("")
assert is_valid is False
def test_validate_password_with_special_chars(self):
"""Test that special characters don't interfere with validation."""
passwords_with_special = [
"P@ssw0rd!",
"MyP@ss123",
"Test#Pass1",
]
for password in passwords_with_special:
is_valid, error = validate_password_strength(password)
assert is_valid is True, f"Password '{password}' should be valid"
assert error == ""
class TestPasswordSecurityProperties:
"""Test security properties of password handling."""
def test_hashed_password_not_reversible(self):
"""Test that hashed passwords cannot be easily reversed."""
password = "TestPassword123"
hashed = hash_password(password)
# Hash should not contain original password
assert password not in hashed
assert password.lower() not in hashed.lower()
def test_different_passwords_different_hashes(self):
"""Test that different passwords produce different hashes."""
password1 = "TestPassword123"
password2 = "TestPassword124" # Only last char different
hash1 = hash_password(password1)
hash2 = hash_password(password2)
assert hash1 != hash2
def test_hashed_password_length_consistent(self):
"""Test that bcrypt hashes have consistent length."""
passwords = ["Short1A", "MediumPassword123", "VeryLongPasswordWithLotsOfCharacters123"]
hashes = [hash_password(p) for p in passwords]
# All bcrypt hashes should be 60 characters
for hashed in hashes:
assert len(hashed) == 60
def test_verify_handles_timing_attack_resistant(self):
"""Test that verification doesn't leak timing information (bcrypt property)."""
# This is more of a documentation test - bcrypt is designed to be timing-attack resistant
password = "TestPassword123"
hashed = hash_password(password)
# Both should take roughly the same time (bcrypt property)
verify_password("WrongPassword123", hashed)
verify_password(password, hashed)
# No actual timing measurement here, just documenting the property
assert True

107
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,107 @@
"""Pytest configuration and fixtures for all tests."""
import os
from typing import Generator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.deps import get_db
from app.database.base import Base
from app.main import app
# Use in-memory SQLite for tests
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db() -> Generator[Session, None, None]:
"""
Create a fresh database for each test.
Yields:
Database session
"""
# Create all tables
Base.metadata.create_all(bind=engine)
# Create session
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
# Drop all tables after test
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db: Session) -> Generator[TestClient, None, None]:
"""
Create a test client with database override.
Args:
db: Test database session
Yields:
FastAPI test client
"""
def override_get_db():
try:
yield db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def test_user_data() -> dict:
"""
Standard test user data.
Returns:
Dictionary with test user credentials
"""
return {"email": "test@example.com", "password": "TestPassword123"}
@pytest.fixture
def test_user_data_weak_password() -> dict:
"""
Test user data with weak password.
Returns:
Dictionary with weak password
"""
return {"email": "test@example.com", "password": "weak"}
@pytest.fixture
def test_user_data_no_uppercase() -> dict:
"""
Test user data with no uppercase letter.
Returns:
Dictionary with invalid password
"""
return {"email": "test@example.com", "password": "testpassword123"}

View File

@@ -103,14 +103,18 @@
type = "app"; type = "app";
program = "${pkgs.writeShellScript "lint" '' program = "${pkgs.writeShellScript "lint" ''
set -e set -e
cd ${self}
# Backend Python linting # Backend Python linting
echo "🔍 Linting backend Python code..." echo "🔍 Linting backend Python code..."
cd backend if [ -d "backend" ]; then
${pkgs.ruff}/bin/ruff check --no-cache app/ cd backend
${pkgs.ruff}/bin/ruff format --check app/ ${pkgs.ruff}/bin/ruff check --no-cache app/
cd .. ${pkgs.ruff}/bin/ruff format --check app/
cd ..
else
echo " Not in project root (backend/ not found)"
exit 1
fi
# Frontend linting (if node_modules exists) # Frontend linting (if node_modules exists)
if [ -d "frontend/node_modules" ]; then if [ -d "frontend/node_modules" ]; then
@@ -118,7 +122,7 @@
echo "🔍 Linting frontend TypeScript/Svelte code..." echo "🔍 Linting frontend TypeScript/Svelte code..."
cd frontend cd frontend
npm run lint npm run lint
npx prettier --check src/ ${pkgs.nodePackages.prettier}/bin/prettier --check src/
npm run check npm run check
cd .. cd ..
else else
@@ -135,19 +139,23 @@
type = "app"; type = "app";
program = "${pkgs.writeShellScript "lint-fix" '' program = "${pkgs.writeShellScript "lint-fix" ''
set -e set -e
cd ${self}
echo "🔧 Auto-fixing backend Python code..." echo "🔧 Auto-fixing backend Python code..."
cd backend if [ -d "backend" ]; then
${pkgs.ruff}/bin/ruff check --fix --no-cache app/ cd backend
${pkgs.ruff}/bin/ruff format app/ ${pkgs.ruff}/bin/ruff check --fix --no-cache app/ || true
cd .. ${pkgs.ruff}/bin/ruff format app/
cd ..
else
echo " Not in project root (backend/ not found)"
exit 1
fi
if [ -d "frontend/node_modules" ]; then if [ -d "frontend/node_modules" ]; then
echo "" echo ""
echo "🔧 Auto-fixing frontend code..." echo "🔧 Auto-fixing frontend code..."
cd frontend cd frontend
npx prettier --write src/ ${pkgs.nodePackages.prettier}/bin/prettier --write src/
cd .. cd ..
fi fi

View File

@@ -0,0 +1,505 @@
/**
* Component tests for authentication forms
* Tests LoginForm and RegisterForm Svelte components
*/
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import LoginForm from '$lib/components/auth/LoginForm.svelte';
import RegisterForm from '$lib/components/auth/RegisterForm.svelte';
describe('LoginForm', () => {
describe('Rendering', () => {
it('renders email and password fields', () => {
render(LoginForm);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it('renders submit button with correct text', () => {
render(LoginForm);
const button = screen.getByRole('button', { name: /login/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it('shows loading state when isLoading prop is true', () => {
render(LoginForm, { props: { isLoading: true } });
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(screen.getByText(/logging in/i)).toBeInTheDocument();
});
it('has proper autocomplete attributes', () => {
render(LoginForm);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
expect(emailInput).toHaveAttribute('autocomplete', 'email');
expect(passwordInput).toHaveAttribute('autocomplete', 'current-password');
});
});
describe('Validation', () => {
it('shows error when email is empty on submit', async () => {
render(LoginForm);
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
it('shows error when email is invalid', async () => {
render(LoginForm);
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: 'invalid-email' } });
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
expect(await screen.findByText(/valid email address/i)).toBeInTheDocument();
});
it('shows error when password is empty on submit', async () => {
render(LoginForm);
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
});
it('accepts valid email formats', async () => {
const validEmails = ['test@example.com', 'user+tag@domain.co.uk', 'first.last@example.com'];
for (const email of validEmails) {
const { unmount } = render(LoginForm);
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: email } });
const passwordInput = screen.getByLabelText(/password/i);
await fireEvent.input(passwordInput, { target: { value: 'password123' } });
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
// Should not show email error
expect(screen.queryByText(/valid email address/i)).not.toBeInTheDocument();
unmount();
}
});
it('clears errors when form is corrected', async () => {
render(LoginForm);
// Submit with empty email
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
// Fix email
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
// Submit again
await fireEvent.click(button);
// Email error should be gone, but password error should appear
expect(screen.queryByText(/email is required/i)).not.toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
});
});
describe('Submission', () => {
it('dispatches submit event with correct data on valid form', async () => {
const { component } = render(LoginForm);
const submitHandler = vi.fn();
component.$on('submit', submitHandler);
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/password/i);
await fireEvent.input(passwordInput, { target: { value: 'TestPassword123' } });
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
await waitFor(() => {
expect(submitHandler).toHaveBeenCalledTimes(1);
});
const event = submitHandler.mock.calls[0][0];
expect(event.detail).toEqual({
email: 'test@example.com',
password: 'TestPassword123',
});
});
it('does not dispatch submit event when form is invalid', async () => {
const { component } = render(LoginForm);
const submitHandler = vi.fn();
component.$on('submit', submitHandler);
// Try to submit with empty fields
const button = screen.getByRole('button', { name: /login/i });
await fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
expect(submitHandler).not.toHaveBeenCalled();
});
it('disables all inputs when loading', () => {
render(LoginForm, { props: { isLoading: true } });
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const button = screen.getByRole('button');
expect(emailInput).toBeDisabled();
expect(passwordInput).toBeDisabled();
expect(button).toBeDisabled();
});
});
});
describe('RegisterForm', () => {
describe('Rendering', () => {
it('renders all required fields', () => {
render(RegisterForm);
expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
});
it('renders submit button with correct text', () => {
render(RegisterForm);
const button = screen.getByRole('button', { name: /create account/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it('shows password requirements help text', () => {
render(RegisterForm);
expect(
screen.getByText(/must be 8\+ characters with uppercase, lowercase, and number/i)
).toBeInTheDocument();
});
it('shows loading state when isLoading prop is true', () => {
render(RegisterForm, { props: { isLoading: true } });
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(screen.getByText(/creating account/i)).toBeInTheDocument();
});
it('has proper autocomplete attributes', () => {
render(RegisterForm);
const emailInput = screen.getByLabelText(/^email$/i);
const passwordInput = screen.getByLabelText(/^password$/i);
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
expect(emailInput).toHaveAttribute('autocomplete', 'email');
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password');
expect(confirmPasswordInput).toHaveAttribute('autocomplete', 'new-password');
});
});
describe('Email Validation', () => {
it('shows error when email is empty', async () => {
render(RegisterForm);
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
it('shows error when email is invalid', async () => {
render(RegisterForm);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'not-an-email' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/valid email address/i)).toBeInTheDocument();
});
});
describe('Password Strength Validation', () => {
it('shows error when password is too short', async () => {
render(RegisterForm);
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'Test1' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
});
it('shows error when password lacks uppercase letter', async () => {
render(RegisterForm);
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'testpassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/uppercase letter/i)).toBeInTheDocument();
});
it('shows error when password lacks lowercase letter', async () => {
render(RegisterForm);
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'TESTPASSWORD123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/lowercase letter/i)).toBeInTheDocument();
});
it('shows error when password lacks number', async () => {
render(RegisterForm);
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'TestPassword' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/contain a number/i)).toBeInTheDocument();
});
it('accepts valid password meeting all requirements', async () => {
render(RegisterForm);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
// Should not show password strength errors
expect(screen.queryByText(/at least 8 characters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/uppercase letter/i)).not.toBeInTheDocument();
expect(screen.queryByText(/lowercase letter/i)).not.toBeInTheDocument();
expect(screen.queryByText(/contain a number/i)).not.toBeInTheDocument();
});
});
describe('Password Confirmation Validation', () => {
it('shows error when confirm password is empty', async () => {
render(RegisterForm);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/confirm your password/i)).toBeInTheDocument();
});
it('shows error when passwords do not match', async () => {
render(RegisterForm);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
await fireEvent.input(confirmPasswordInput, { target: { value: 'DifferentPassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
});
it('accepts matching passwords', async () => {
render(RegisterForm);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
// Should not show confirmation error
expect(screen.queryByText(/passwords do not match/i)).not.toBeInTheDocument();
});
});
describe('Submission', () => {
it('dispatches submit event with correct data on valid form', async () => {
const { component } = render(RegisterForm);
const submitHandler = vi.fn();
component.$on('submit', submitHandler);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
await waitFor(() => {
expect(submitHandler).toHaveBeenCalledTimes(1);
});
const event = submitHandler.mock.calls[0][0];
expect(event.detail).toEqual({
email: 'test@example.com',
password: 'ValidPassword123',
});
});
it('does not include confirmPassword in submit event', async () => {
const { component } = render(RegisterForm);
const submitHandler = vi.fn();
component.$on('submit', submitHandler);
const emailInput = screen.getByLabelText(/^email$/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
await waitFor(() => {
expect(submitHandler).toHaveBeenCalled();
});
const event = submitHandler.mock.calls[0][0];
expect(event.detail).not.toHaveProperty('confirmPassword');
});
it('does not dispatch submit event when form is invalid', async () => {
const { component } = render(RegisterForm);
const submitHandler = vi.fn();
component.$on('submit', submitHandler);
// Try to submit with empty fields
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
expect(submitHandler).not.toHaveBeenCalled();
});
it('disables all inputs when loading', () => {
render(RegisterForm, { props: { isLoading: true } });
const emailInput = screen.getByLabelText(/^email$/i);
const passwordInput = screen.getByLabelText(/^password$/i);
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
const button = screen.getByRole('button');
expect(emailInput).toBeDisabled();
expect(passwordInput).toBeDisabled();
expect(confirmPasswordInput).toBeDisabled();
expect(button).toBeDisabled();
});
});
describe('User Experience', () => {
it('hides help text when password error is shown', async () => {
render(RegisterForm);
// Help text should be visible initially
expect(
screen.getByText(/must be 8\+ characters with uppercase, lowercase, and number/i)
).toBeInTheDocument();
// Enter invalid password
const passwordInput = screen.getByLabelText(/^password$/i);
await fireEvent.input(passwordInput, { target: { value: 'short' } });
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
// Error should be shown
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
// Help text should be hidden
expect(
screen.queryByText(/must be 8\+ characters with uppercase, lowercase, and number/i)
).not.toBeInTheDocument();
});
it('validates all fields independently', async () => {
render(RegisterForm);
const button = screen.getByRole('button', { name: /create account/i });
await fireEvent.click(button);
// All errors should be shown
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
expect(await screen.findByText(/confirm your password/i)).toBeInTheDocument();
});
});
});

View File

@@ -110,9 +110,9 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
- [X] T042 [US1] Implement login endpoint POST /auth/login in backend/app/api/auth.py - [X] T042 [US1] Implement login endpoint POST /auth/login in backend/app/api/auth.py
- [X] T043 [US1] Implement current user endpoint GET /auth/me in backend/app/api/auth.py - [X] T043 [US1] Implement current user endpoint GET /auth/me in backend/app/api/auth.py
- [X] T044 [US1] Create JWT validation dependency in backend/app/core/deps.py (get_current_user) - [X] T044 [US1] Create JWT validation dependency in backend/app/core/deps.py (get_current_user)
- [ ] T045 [P] [US1] Write unit tests for password hashing in backend/tests/auth/test_security.py - [X] T045 [P] [US1] Write unit tests for password hashing in backend/tests/auth/test_security.py
- [ ] T046 [P] [US1] Write unit tests for JWT generation in backend/tests/auth/test_jwt.py - [X] T046 [P] [US1] Write unit tests for JWT generation in backend/tests/auth/test_jwt.py
- [ ] T047 [P] [US1] Write integration tests for auth endpoints in backend/tests/api/test_auth.py - [X] T047 [P] [US1] Write integration tests for auth endpoints in backend/tests/api/test_auth.py
**Frontend Tasks:** **Frontend Tasks:**
@@ -123,7 +123,7 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
- [X] T052 [US1] Implement route protection in frontend/src/hooks.server.ts - [X] T052 [US1] Implement route protection in frontend/src/hooks.server.ts
- [X] T053 [P] [US1] Create LoginForm component in frontend/src/lib/components/auth/LoginForm.svelte - [X] T053 [P] [US1] Create LoginForm component in frontend/src/lib/components/auth/LoginForm.svelte
- [X] T054 [P] [US1] Create RegisterForm component in frontend/src/lib/components/auth/RegisterForm.svelte - [X] T054 [P] [US1] Create RegisterForm component in frontend/src/lib/components/auth/RegisterForm.svelte
- [ ] T055 [P] [US1] Write component tests for auth forms in frontend/tests/components/auth.test.ts - [X] T055 [P] [US1] Write component tests for auth forms in frontend/tests/components/auth.test.ts
**Deliverables:** **Deliverables:**
- Complete authentication system - Complete authentication system
@@ -146,15 +146,15 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
**Backend Tasks:** **Backend Tasks:**
- [ ] T056 [P] [US2] Create Board model in backend/app/database/models/board.py from data-model.md - [X] T056 [P] [US2] Create Board model in backend/app/database/models/board.py from data-model.md
- [ ] T057 [P] [US2] Create board schemas in backend/app/boards/schemas.py (BoardCreate, BoardUpdate, BoardResponse) - [X] T057 [P] [US2] Create board schemas in backend/app/boards/schemas.py (BoardCreate, BoardUpdate, BoardResponse)
- [ ] T058 [US2] Create board repository in backend/app/boards/repository.py (CRUD operations) - [X] T058 [US2] Create board repository in backend/app/boards/repository.py (CRUD operations)
- [ ] T059 [US2] Implement create board endpoint POST /boards in backend/app/api/boards.py - [X] T059 [US2] Implement create board endpoint POST /boards in backend/app/api/boards.py
- [ ] T060 [US2] Implement list boards endpoint GET /boards in backend/app/api/boards.py - [X] T060 [US2] Implement list boards endpoint GET /boards in backend/app/api/boards.py
- [ ] T061 [US2] Implement get board endpoint GET /boards/{id} in backend/app/api/boards.py - [X] T061 [US2] Implement get board endpoint GET /boards/{id} in backend/app/api/boards.py
- [ ] T062 [US2] Implement update board endpoint PATCH /boards/{id} in backend/app/api/boards.py - [X] T062 [US2] Implement update board endpoint PATCH /boards/{id} in backend/app/api/boards.py
- [ ] T063 [US2] Implement delete board endpoint DELETE /boards/{id} in backend/app/api/boards.py - [X] T063 [US2] Implement delete board endpoint DELETE /boards/{id} in backend/app/api/boards.py
- [ ] T064 [US2] Add ownership validation middleware in backend/app/boards/permissions.py - [X] T064 [US2] Add ownership validation middleware in backend/app/boards/permissions.py
- [ ] T065 [P] [US2] Write unit tests for board repository in backend/tests/boards/test_repository.py - [ ] T065 [P] [US2] Write unit tests for board repository in backend/tests/boards/test_repository.py
- [ ] T066 [P] [US2] Write integration tests for board endpoints in backend/tests/api/test_boards.py - [ ] T066 [P] [US2] Write integration tests for board endpoints in backend/tests/api/test_boards.py