Compare commits
5 Commits
002-api-im
...
004-jwt-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 86961d19ee | |||
| 5fbbc1e67f | |||
| d91a65abe5 | |||
| ec7bf591a4 | |||
| f953c88984 |
@@ -13,3 +13,9 @@ API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Upload size limit in bytes (default 50 MiB)
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
|
||||
# Owner credentials and JWT signing secret
|
||||
JWT_SECRET_KEY=change-me-to-a-long-random-string
|
||||
JWT_EXPIRY_SECONDS=86400
|
||||
OWNER_USERNAME=owner
|
||||
OWNER_PASSWORD=change-me
|
||||
|
||||
BIN
.img/reactbin-ui.png
Normal file
BIN
.img/reactbin-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/002-api-image-proxy"
|
||||
"feature_directory": "specs/004-jwt-bearer-auth"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: [TEMPLATE — no prior version] → 1.1.0
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-02
|
||||
Version change: 1.1.0 → 1.1.1
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-03
|
||||
|
||||
Principles introduced (first population from docs/CONSTITUTION.md):
|
||||
- §2 Architecture Principles (6 sub-principles)
|
||||
@@ -199,16 +199,16 @@ NOT be marked complete while CI is failing.
|
||||
|
||||
## 6. Tech Stack Constraints
|
||||
|
||||
| Concern | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| API language | Python 3.12+ | Primary language, type hints required |
|
||||
| API framework | FastAPI | Async, OpenAPI-native |
|
||||
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
||||
| DB migrations | Alembic | Schema changes tracked in version control |
|
||||
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||
| Concern | Choice | Rationale |
|
||||
|------------------|-------------------------------------------|-------------------------------------------|
|
||||
| API language | Python 3.12+ | Primary language, type hints required |
|
||||
| API framework | FastAPI | Async, OpenAPI-native |
|
||||
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
||||
| DB migrations | Alembic | Schema changes tracked in version control |
|
||||
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||
|
||||
---
|
||||
|
||||
@@ -241,7 +241,7 @@ revised:
|
||||
- Multi-user support
|
||||
- Public sharing or embeds
|
||||
- Collections or albums beyond tag-based grouping
|
||||
- Image editing or transformation
|
||||
- Image editing or transformation beyond thumbnail generation
|
||||
- OR/NOT tag logic
|
||||
- Mobile-native app
|
||||
- Username/password auth (planned Phase 2)
|
||||
@@ -277,12 +277,13 @@ Phase 1 design is complete.
|
||||
|
||||
## 10. Revision Log
|
||||
|
||||
| Version | Date | Change |
|
||||
|---|---|---|
|
||||
| 1.0.0 | 2026-05-01 | Initial constitution |
|
||||
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
||||
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
||||
| Version | Date | Change |
|
||||
|---------|------------|---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 1.0.0 | 2026-05-01 | Initial constitution |
|
||||
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
||||
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
||||
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-02
|
||||
**Version**: 1.1.1 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-03
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/002-api-image-proxy/plan.md`.
|
||||
`specs/004-jwt-bearer-auth/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
# reactbin
|
||||
Organize your reaction images.
|
||||
_Organize your reaction images._
|
||||
|
||||

|
||||
|
||||
23
api/alembic/versions/002_add_thumbnail_key.py
Normal file
23
api/alembic/versions/002_add_thumbnail_key.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""add thumbnail_key column to images
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-05-03
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("images", sa.Column("thumbnail_key", sa.String(70), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("images", "thumbnail_key")
|
||||
51
api/app/auth/jwt_provider.py
Normal file
51
api/app/auth/jwt_provider.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.provider import AuthProvider, Identity
|
||||
|
||||
_UNAUTHORIZED = HTTPException(
|
||||
status_code=401, detail={"detail": "Unauthorized", "code": "unauthorized"}
|
||||
)
|
||||
|
||||
|
||||
class JWTAuthProvider(AuthProvider):
|
||||
def __init__(
|
||||
self,
|
||||
secret_key: str,
|
||||
expiry_seconds: int,
|
||||
owner_username: str,
|
||||
owner_password: str,
|
||||
) -> None:
|
||||
self._secret_key = secret_key
|
||||
self._expiry_seconds = expiry_seconds
|
||||
self._owner_username = owner_username
|
||||
self._owner_password = owner_password
|
||||
|
||||
def create_token(self) -> str:
|
||||
now = datetime.now(tz=UTC)
|
||||
payload = {
|
||||
"sub": "owner",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=self._expiry_seconds),
|
||||
}
|
||||
return jwt.encode(payload, self._secret_key, algorithm="HS256")
|
||||
|
||||
def verify_credentials(self, username: str, password: str) -> bool:
|
||||
username_ok = secrets.compare_digest(username, self._owner_username)
|
||||
password_ok = secrets.compare_digest(password, self._owner_password)
|
||||
return username_ok and password_ok
|
||||
|
||||
async def get_identity(self, authorization: str | None) -> Identity:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise _UNAUTHORIZED
|
||||
token = authorization.removeprefix("Bearer ")
|
||||
try:
|
||||
jwt.decode(token, self._secret_key, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise _UNAUTHORIZED from None
|
||||
except jwt.InvalidTokenError:
|
||||
raise _UNAUTHORIZED from None
|
||||
return Identity(id="owner", anonymous=False)
|
||||
@@ -4,5 +4,5 @@ _ANONYMOUS = Identity(id="anonymous", anonymous=True)
|
||||
|
||||
|
||||
class NoOpAuthProvider(AuthProvider):
|
||||
async def get_identity(self) -> Identity:
|
||||
async def get_identity(self, authorization: str | None) -> Identity:
|
||||
return _ANONYMOUS
|
||||
|
||||
@@ -10,5 +10,5 @@ class Identity:
|
||||
|
||||
class AuthProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_identity(self) -> Identity:
|
||||
"""Resolve the request identity."""
|
||||
async def get_identity(self, authorization: str | None) -> Identity:
|
||||
"""Resolve the request identity from the Authorization header value."""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -13,6 +14,10 @@ class Settings(BaseSettings):
|
||||
s3_region: str = "us-east-1"
|
||||
api_base_url: str = "http://localhost:8000"
|
||||
max_upload_bytes: int = 52_428_800 # 50 MiB
|
||||
jwt_secret_key: str
|
||||
jwt_expiry_seconds: int = 86400
|
||||
owner_username: str
|
||||
owner_password: str
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.noop import NoOpAuthProvider
|
||||
from app.auth.provider import AuthProvider
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.auth.provider import AuthProvider, Identity
|
||||
from app.database import get_session_factory
|
||||
from app.storage.backend import StorageBackend
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
@@ -23,12 +23,38 @@ def get_storage() -> StorageBackend:
|
||||
def get_auth() -> AuthProvider:
|
||||
global _auth
|
||||
if _auth is None:
|
||||
_auth = NoOpAuthProvider()
|
||||
from app.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
_auth = JWTAuthProvider(
|
||||
secret_key=s.jwt_secret_key,
|
||||
expiry_seconds=s.jwt_expiry_seconds,
|
||||
owner_username=s.owner_username,
|
||||
owner_password=s.owner_password,
|
||||
)
|
||||
return _auth
|
||||
|
||||
|
||||
def get_jwt_auth() -> JWTAuthProvider:
|
||||
auth = get_auth()
|
||||
assert isinstance(auth, JWTAuthProvider)
|
||||
return auth
|
||||
|
||||
|
||||
async def require_auth(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
) -> Identity:
|
||||
identity = await auth.get_identity(authorization)
|
||||
if identity.anonymous:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"detail": "Authentication required", "code": "unauthorized"},
|
||||
)
|
||||
return identity
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
async with factory() as session, session.begin():
|
||||
yield session
|
||||
|
||||
@@ -5,12 +5,12 @@ from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_engine, get_session_factory, Base
|
||||
from app.database import Base, get_engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI):
|
||||
settings = get_settings()
|
||||
get_settings()
|
||||
# Verify DB connection and run migrations on startup
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
@@ -36,7 +36,8 @@ async def health():
|
||||
|
||||
|
||||
# Routers registered after all modules are defined to avoid circular imports
|
||||
from app.routers import images, tags # noqa: E402
|
||||
from app.routers import auth, images, tags # noqa: E402
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(images.router, prefix="/api/v1")
|
||||
app.include_router(tags.router, prefix="/api/v1")
|
||||
|
||||
@@ -23,6 +23,7 @@ class Image(Base):
|
||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan")
|
||||
|
||||
@@ -34,6 +34,7 @@ class ImageRepository:
|
||||
width: int,
|
||||
height: int,
|
||||
storage_key: str,
|
||||
thumbnail_key: str | None = None,
|
||||
) -> Image:
|
||||
image = Image(
|
||||
hash=hash_hex,
|
||||
@@ -43,6 +44,7 @@ class ImageRepository:
|
||||
width=width,
|
||||
height=height,
|
||||
storage_key=storage_key,
|
||||
thumbnail_key=thumbnail_key,
|
||||
)
|
||||
self._session.add(image)
|
||||
await self._session.flush()
|
||||
|
||||
33
api/app/routers/auth.py
Normal file
33
api/app/routers/auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.dependencies import get_jwt_auth
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post("/auth/token", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, auth: JWTAuthProvider = Depends(get_jwt_auth)):
|
||||
if not auth.verify_credentials(body.username, body.password):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||
)
|
||||
token = auth.create_token()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=auth._expiry_seconds,
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import uuid
|
||||
from typing import Any
|
||||
@@ -5,16 +7,19 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.provider import AuthProvider
|
||||
from app.auth.provider import Identity
|
||||
from app.config import get_settings
|
||||
from app.dependencies import get_auth, get_db, get_storage
|
||||
from app.dependencies import get_db, get_storage, require_auth
|
||||
from app.models import Image
|
||||
from app.repositories.image_repo import ImageRepository
|
||||
from app.repositories.tag_repo import TagRepository
|
||||
from app.storage.backend import StorageBackend
|
||||
from app.thumbnail import generate_thumbnail
|
||||
from app.utils import compute_sha256
|
||||
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["images"])
|
||||
|
||||
|
||||
@@ -32,6 +37,7 @@ def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"storage_key": image.storage_key,
|
||||
"thumbnail_key": image.thumbnail_key,
|
||||
"created_at": image.created_at.isoformat(),
|
||||
"tags": image.tags,
|
||||
}
|
||||
@@ -103,7 +109,7 @@ async def upload_image(
|
||||
tags: str | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
_: Identity = Depends(require_auth),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
data = await file.read()
|
||||
@@ -151,6 +157,16 @@ async def upload_image(
|
||||
width, height = _read_image_dimensions(data, mime_type)
|
||||
await storage.put(hash_hex, data, mime_type)
|
||||
|
||||
thumbnail_key: str | None = None
|
||||
try:
|
||||
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
|
||||
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
|
||||
thumbnail_key = f"{hash_hex}-thumb"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex
|
||||
)
|
||||
|
||||
image = await image_repo.create(
|
||||
hash_hex=hash_hex,
|
||||
filename=file.filename or "upload",
|
||||
@@ -159,6 +175,7 @@ async def upload_image(
|
||||
width=width,
|
||||
height=height,
|
||||
storage_key=hash_hex,
|
||||
thumbnail_key=thumbnail_key,
|
||||
)
|
||||
|
||||
if tag_names:
|
||||
@@ -233,11 +250,44 @@ async def serve_image_file(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/images/{image_id}/thumbnail")
|
||||
async def serve_image_thumbnail(
|
||||
image_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
):
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"detail": "Image not found", "code": "image_not_found"},
|
||||
)
|
||||
key = image.thumbnail_key or image.storage_key
|
||||
media_type = "image/webp" if image.thumbnail_key else image.mime_type
|
||||
try:
|
||||
data = await storage.get(key)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"detail": "Failed to retrieve image content", "code": "storage_error"},
|
||||
) from None
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"ETag": f'"{image.hash}"',
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/images/{image_id}/tags")
|
||||
async def update_image_tags(
|
||||
image_id: uuid.UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: Identity = Depends(require_auth),
|
||||
):
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
@@ -267,6 +317,7 @@ async def delete_image(
|
||||
image_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageBackend = Depends(get_storage),
|
||||
_: Identity = Depends(require_auth),
|
||||
):
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
@@ -276,6 +327,9 @@ async def delete_image(
|
||||
detail={"detail": "Image not found", "code": "image_not_found"},
|
||||
)
|
||||
storage_key = image.storage_key
|
||||
thumbnail_key = image.thumbnail_key
|
||||
await image_repo.delete(image)
|
||||
await storage.delete(storage_key)
|
||||
if thumbnail_key:
|
||||
await storage.delete(thumbnail_key)
|
||||
return Response(status_code=204)
|
||||
|
||||
16
api/app/thumbnail.py
Normal file
16
api/app/thumbnail.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def generate_thumbnail(data: bytes, mime_type: str) -> bytes:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
with contextlib.suppress(EOFError):
|
||||
img.seek(0)
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA" if img.mode == "P" and "transparency" in img.info else "RGB")
|
||||
img.thumbnail((400, 400), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="WEBP", quality=80)
|
||||
return buf.getvalue()
|
||||
@@ -15,6 +15,8 @@ dependencies = [
|
||||
"aiobotocore>=2.13",
|
||||
"pydantic-settings>=2.2",
|
||||
"python-multipart>=0.0.9",
|
||||
"pillow>=10.0",
|
||||
"PyJWT>=2.8",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -31,7 +33,10 @@ target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = []
|
||||
ignore = [
|
||||
"B008", # FastAPI Depends/File/Form in function signatures — intentional
|
||||
"B904", # raise-without-from inside except — HTTPException re-raise pattern
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
@@ -42,3 +47,11 @@ testpaths = ["tests"]
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"anyio>=4.13.0",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
|
||||
# Provide required settings for the test environment before any app imports resolve them
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OWNER_USERNAME", "testowner")
|
||||
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
|
||||
|
||||
from app.main import app
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.dependencies import get_db, get_storage, get_auth
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
|
||||
# Bust the LRU cache so get_settings() picks up the env vars set above
|
||||
get_settings.cache_clear()
|
||||
|
||||
_TEST_JWT_SECRET = os.environ["JWT_SECRET_KEY"]
|
||||
_TEST_OWNER_USERNAME = os.environ["OWNER_USERNAME"]
|
||||
_TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
@@ -57,3 +71,40 @@ async def client(db_session):
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def jwt_auth_provider() -> JWTAuthProvider:
|
||||
return JWTAuthProvider(
|
||||
secret_key=_TEST_JWT_SECRET,
|
||||
expiry_seconds=3600,
|
||||
owner_username=_TEST_OWNER_USERNAME,
|
||||
owner_password=_TEST_OWNER_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(db_session, jwt_auth_provider):
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
|
||||
storage = S3StorageBackend()
|
||||
auth = jwt_auth_provider
|
||||
|
||||
async def override_db():
|
||||
yield db_session
|
||||
|
||||
def override_storage():
|
||||
return storage
|
||||
|
||||
def override_auth():
|
||||
return auth
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_storage] = override_storage
|
||||
app.dependency_overrides[get_auth] = override_auth
|
||||
|
||||
valid_token = auth.create_token()
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c, valid_token
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
51
api/tests/integration/test_auth.py
Normal file
51
api/tests/integration/test_auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
|
||||
_VALID_CREDS = {"username": "testowner", "password": "testpassword"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post("/api/v1/auth/token", json=_VALID_CREDS)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert isinstance(body.get("access_token"), str)
|
||||
assert len(body["access_token"]) > 0
|
||||
assert body.get("token_type") == "bearer"
|
||||
assert body.get("expires_in", 0) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
json={"username": "testowner", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "invalid_credentials"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_username(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
json={"username": "notowner", "password": "testpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "invalid_credentials"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_password(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post("/api/v1/auth/token", json={"username": "testowner"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_username(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.post("/api/v1/auth/token", json={"password": "testpassword"})
|
||||
assert response.status_code == 422
|
||||
@@ -5,7 +5,9 @@ T067 — DELETE of unknown ID → 404 image_not_found
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def _minimal_jpeg_v2() -> bytes:
|
||||
@@ -58,3 +60,25 @@ async def test_delete_unknown_id_returns_404(client):
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_thumbnail(client):
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
|
||||
data = buf.getvalue()
|
||||
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
image_id = upload.json()["id"]
|
||||
assert upload.json()["thumbnail_key"] is not None
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
||||
assert thumb_resp.status_code == 404
|
||||
assert thumb_resp.json()["code"] == "image_not_found"
|
||||
|
||||
95
api/tests/integration/test_protected.py
Normal file
95
api/tests/integration/test_protected.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Tests that write endpoints require authentication (US2).
|
||||
These use the authed_client fixture which wires JWTAuthProvider.
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _minimal_jpeg() -> bytes:
|
||||
return (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x02"
|
||||
b"\xff\xd9"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_without_token_returns_401(authed_client):
|
||||
client, _ = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "unauthorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_valid_token_succeeds(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code in (200, 201)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_without_token_returns_401(authed_client):
|
||||
client, _ = authed_client
|
||||
fake_id = uuid.uuid4()
|
||||
response = await client.delete(f"/api/v1/images/{fake_id}")
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "unauthorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_with_valid_token_succeeds(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
response = await client.delete(
|
||||
f"/api/v1/images/{image_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_tags_without_token_returns_401(authed_client):
|
||||
client, _ = authed_client
|
||||
fake_id = uuid.uuid4()
|
||||
response = await client.patch(
|
||||
f"/api/v1/images/{fake_id}/tags",
|
||||
json={"tags": ["a"]},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json().get("code") == "unauthorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_tags_with_valid_token_succeeds(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
response = await client.patch(
|
||||
f"/api/v1/images/{image_id}/tags",
|
||||
json={"tags": ["protected-tag"]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
71
api/tests/integration/test_public_access.py
Normal file
71
api/tests/integration/test_public_access.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
US3 regression tests: all read endpoints must remain accessible without a token
|
||||
even after require_auth is applied to write endpoints.
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _minimal_jpeg() -> bytes:
|
||||
return (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x03"
|
||||
b"\xff\xd9"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_without_token_is_200(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.get("/api/v1/images")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_without_token_is_200(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
response = await client.get(f"/api/v1/images/{image_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_file_without_token_is_200(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_thumbnail_without_token_is_200(authed_client):
|
||||
client, token = authed_client
|
||||
data = _minimal_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
image_id = upload.json()["id"]
|
||||
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_without_token_is_200(authed_client):
|
||||
client, _ = authed_client
|
||||
response = await client.get("/api/v1/tags")
|
||||
assert response.status_code == 200
|
||||
@@ -7,6 +7,16 @@ import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models import Image
|
||||
|
||||
|
||||
def _real_jpeg() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", (200, 150), color=(120, 80, 200)).save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _minimal_webp() -> bytes:
|
||||
@@ -62,3 +72,53 @@ async def test_file_response_exposes_no_storage_details(client):
|
||||
assert "minio" not in response.text.lower()
|
||||
assert "s3://" not in response.text.lower()
|
||||
assert "amazonaws.com" not in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_returns_webp(client):
|
||||
data = _real_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
body = upload.json()
|
||||
image_id = body["id"]
|
||||
image_hash = body["hash"]
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/webp"
|
||||
assert response.headers["etag"] == f'"{image_hash}"'
|
||||
assert "immutable" in response.headers["cache-control"]
|
||||
assert len(response.content) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_fallback_returns_original(client, db_session):
|
||||
data = _real_jpeg()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
image_id = upload.json()["id"]
|
||||
|
||||
await db_session.execute(
|
||||
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
|
||||
)
|
||||
await db_session.flush()
|
||||
db_session.expire_all()
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
assert "image/jpeg" in response.headers["content-type"]
|
||||
assert len(response.content) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_unknown_id_returns_404(client):
|
||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
@@ -6,7 +6,16 @@ T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
|
||||
T079 — GET /api/v1/images/{id} 404 → error envelope shape
|
||||
"""
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def _real_jpeg(color: tuple = (100, 150, 200), size: tuple = (200, 150)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
PILImage.new("RGB", size, color=color).save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _minimal_jpeg() -> bytes:
|
||||
@@ -96,3 +105,51 @@ async def test_get_unknown_image_returns_404_with_envelope(client):
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
assert "detail" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_returns_thumbnail_key(client):
|
||||
data = _real_jpeg(color=(100, 150, 200))
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "thumbnail_key" in body
|
||||
assert body["thumbnail_key"] is not None
|
||||
assert body["thumbnail_key"].endswith("-thumb")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_upload_reuses_thumbnail_key(client):
|
||||
data = _real_jpeg(color=(200, 100, 50))
|
||||
r1 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert r1.status_code in (200, 201)
|
||||
|
||||
r2 = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
tk1 = r1.json()["thumbnail_key"]
|
||||
tk2 = r2.json()["thumbnail_key"]
|
||||
assert tk1 is not None
|
||||
assert tk1 == tk2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_succeeds_when_thumbnail_fails(client):
|
||||
data = _real_jpeg(color=(50, 200, 150))
|
||||
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
|
||||
response = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code in (200, 201)
|
||||
body = response.json()
|
||||
assert body["thumbnail_key"] is None
|
||||
|
||||
@@ -2,14 +2,27 @@ import os
|
||||
import pytest
|
||||
|
||||
|
||||
_BASE_ENV = {
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"S3_ENDPOINT_URL": "http://localhost:9000",
|
||||
"S3_BUCKET_NAME": "test-bucket",
|
||||
"S3_ACCESS_KEY_ID": "key",
|
||||
"S3_SECRET_ACCESS_KEY": "secret",
|
||||
"S3_REGION": "us-east-1",
|
||||
"API_BASE_URL": "http://localhost:8000",
|
||||
"JWT_SECRET_KEY": "test-secret",
|
||||
"OWNER_USERNAME": "admin",
|
||||
"OWNER_PASSWORD": "password",
|
||||
}
|
||||
|
||||
|
||||
def _apply_env(monkeypatch, extra=None):
|
||||
for k, v in {**_BASE_ENV, **(extra or {})}.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
|
||||
|
||||
def test_settings_load_from_env(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
|
||||
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
|
||||
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
|
||||
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
monkeypatch.setenv("S3_REGION", "us-east-1")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
|
||||
_apply_env(monkeypatch)
|
||||
|
||||
# Import inside test to pick up monkeypatched env
|
||||
import importlib
|
||||
@@ -20,17 +33,13 @@ def test_settings_load_from_env(monkeypatch):
|
||||
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
|
||||
assert s.s3_bucket_name == "test-bucket"
|
||||
assert s.max_upload_bytes == 52428800 # default
|
||||
assert s.jwt_secret_key == "test-secret"
|
||||
assert s.jwt_expiry_seconds == 86400 # default
|
||||
assert s.owner_username == "admin"
|
||||
|
||||
|
||||
def test_settings_max_upload_bytes_override(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
|
||||
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
|
||||
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
|
||||
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
monkeypatch.setenv("S3_REGION", "us-east-1")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
|
||||
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
|
||||
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
|
||||
|
||||
import importlib
|
||||
import app.config as config_module
|
||||
@@ -38,3 +47,14 @@ def test_settings_max_upload_bytes_override(monkeypatch):
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.max_upload_bytes == 10485760
|
||||
|
||||
|
||||
def test_settings_jwt_expiry_override(monkeypatch):
|
||||
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
||||
|
||||
import importlib
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.jwt_expiry_seconds == 3600
|
||||
|
||||
97
api/tests/unit/test_jwt_auth.py
Normal file
97
api/tests/unit/test_jwt_auth.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import time
|
||||
import pytest
|
||||
import jwt as pyjwt
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
|
||||
SECRET = "test-secret-key"
|
||||
USERNAME = "owner"
|
||||
PASSWORD = "hunter2"
|
||||
|
||||
|
||||
def make_provider(**kwargs) -> JWTAuthProvider:
|
||||
defaults = dict(
|
||||
secret_key=SECRET,
|
||||
expiry_seconds=3600,
|
||||
owner_username=USERNAME,
|
||||
owner_password=PASSWORD,
|
||||
)
|
||||
return JWTAuthProvider(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
def test_create_token_is_valid_jwt():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
payload = pyjwt.decode(token, SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "owner"
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_returns_owner():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
identity = await provider.get_identity(f"Bearer {token}")
|
||||
assert identity.id == "owner"
|
||||
assert identity.anonymous is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_expired_token():
|
||||
provider = make_provider(expiry_seconds=-1)
|
||||
token = provider.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(f"Bearer {token}")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_wrong_key():
|
||||
provider = make_provider()
|
||||
other = make_provider(secret_key="different-secret")
|
||||
token = other.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(f"Bearer {token}")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_garbage():
|
||||
provider = make_provider()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity("Bearer not.a.real.token")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_missing_header():
|
||||
provider = make_provider()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_missing_bearer_prefix():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(token)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_credentials_true():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials(USERNAME, PASSWORD) is True
|
||||
|
||||
|
||||
def test_verify_credentials_false_wrong_password():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials(USERNAME, "wrongpassword") is False
|
||||
|
||||
|
||||
def test_verify_credentials_false_wrong_username():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials("notowner", PASSWORD) is False
|
||||
79
api/tests/unit/test_thumbnail.py
Normal file
79
api/tests/unit/test_thumbnail.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for thumbnail generation utility."""
|
||||
import io
|
||||
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.thumbnail import generate_thumbnail
|
||||
|
||||
|
||||
def _make_jpeg(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("RGB", (width, height), color=(128, 64, 32))
|
||||
img.save(buf, format="JPEG", quality=80)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_png_rgba(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("RGBA", (width, height), color=(10, 20, 30, 180))
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_gif(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("P", (width, height))
|
||||
img.save(buf, format="GIF")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_thumbnail_is_webp():
|
||||
data = _make_jpeg(600, 400)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
assert result[:4] == b"RIFF"
|
||||
assert result[8:12] == b"WEBP"
|
||||
|
||||
|
||||
def test_thumbnail_fits_within_400px():
|
||||
data = _make_jpeg(800, 600)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
assert w <= 400
|
||||
assert h <= 400
|
||||
|
||||
|
||||
def test_thumbnail_preserves_aspect_ratio():
|
||||
original_w, original_h = 800, 300
|
||||
data = _make_jpeg(original_w, original_h)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
original_ratio = original_w / original_h
|
||||
thumb_ratio = w / h
|
||||
assert abs(original_ratio - thumb_ratio) / original_ratio < 0.01
|
||||
|
||||
|
||||
def test_thumbnail_handles_gif_first_frame():
|
||||
data = _make_gif(500, 500)
|
||||
result = generate_thumbnail(data, "image/gif")
|
||||
assert result[8:12] == b"WEBP"
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
assert not getattr(img, "is_animated", False)
|
||||
|
||||
|
||||
def test_thumbnail_handles_png_with_alpha():
|
||||
data = _make_png_rgba(300, 300)
|
||||
result = generate_thumbnail(data, "image/png")
|
||||
assert result[8:12] == b"WEBP"
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
assert img.format == "WEBP"
|
||||
|
||||
|
||||
def test_thumbnail_does_not_upscale():
|
||||
data = _make_jpeg(100, 100)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
assert w <= 100
|
||||
assert h <= 100
|
||||
1594
api/uv.lock
generated
Normal file
1594
api/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
specs/003-upload-thumbnails/checklists/requirements.md
Normal file
34
specs/003-upload-thumbnails/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Upload Thumbnails
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All checklist items pass. Spec is ready for `/speckit-plan`.
|
||||
90
specs/003-upload-thumbnails/contracts/api.md
Normal file
90
specs/003-upload-thumbnails/contracts/api.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# API Contract: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## New endpoint
|
||||
|
||||
### `GET /api/v1/images/{image_id}/thumbnail`
|
||||
|
||||
Returns the thumbnail content for the given image. If no thumbnail was generated
|
||||
(image pre-dates the feature or generation failed), falls back to the full-size
|
||||
original.
|
||||
|
||||
**Path parameters**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `image_id` | UUID | Unique identifier of the image |
|
||||
|
||||
**Responses**
|
||||
|
||||
#### `200 OK` — Thumbnail (or original fallback) content
|
||||
|
||||
| Header | Value | Notes |
|
||||
|--------|-------|-------|
|
||||
| `Content-Type` | `image/webp` | Always WebP when a thumbnail exists; original `mime_type` when falling back to the original |
|
||||
| `ETag` | `"{sha256-hex}"` | Same hash as the original image — content is immutable |
|
||||
| `Cache-Control` | `public, max-age=31536000, immutable` | Safe: thumbnail content never changes |
|
||||
|
||||
Body: raw image bytes (WebP thumbnail, or original bytes as fallback).
|
||||
|
||||
#### `404 Not Found` — Image not found
|
||||
|
||||
```json
|
||||
{ "detail": "Image not found", "code": "image_not_found" }
|
||||
```
|
||||
|
||||
#### `500 Internal Server Error` — Storage retrieval failure
|
||||
|
||||
```json
|
||||
{ "detail": "Failed to retrieve image content", "code": "storage_error" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changed endpoint: `POST /api/v1/images`
|
||||
|
||||
The upload response body gains one new field:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `thumbnail_key` | `string \| null` | S3 key of the generated thumbnail. `null` if generation failed. |
|
||||
|
||||
All existing fields are unchanged.
|
||||
|
||||
**Example response** (new field only shown):
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"thumbnail_key": "abc123…-thumb",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changed endpoint: `GET /api/v1/images` and `GET /api/v1/images/{id}`
|
||||
|
||||
Both metadata responses gain the same `thumbnail_key` field (`string | null`).
|
||||
|
||||
---
|
||||
|
||||
## UI contract
|
||||
|
||||
The Angular `ImageService` gains one new method:
|
||||
|
||||
```
|
||||
getThumbnailUrl(id: string): string
|
||||
→ '/api/v1/images/{id}/thumbnail'
|
||||
```
|
||||
|
||||
The `ImageRecord` interface gains:
|
||||
|
||||
```
|
||||
thumbnail_key: string | null;
|
||||
```
|
||||
|
||||
The library grid component uses `getThumbnailUrl(image.id)` as the `src` for
|
||||
every grid cell. The detail component continues using `getFileUrl(image.id)`.
|
||||
79
specs/003-upload-thumbnails/data-model.md
Normal file
79
specs/003-upload-thumbnails/data-model.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Data Model: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
## Schema change: `images` table
|
||||
|
||||
One nullable column is added to the existing `images` table.
|
||||
|
||||
| Column | Type | Nullable | Default | Notes |
|
||||
|--------|------|----------|---------|-------|
|
||||
| `thumbnail_key` | `VARCHAR(70)` | YES | `NULL` | S3 object key for the WebP thumbnail. `NULL` = no thumbnail available (generation failed or pre-dates this feature). Derived value: `{image.hash}-thumb`. |
|
||||
|
||||
No other tables change. No new tables are added.
|
||||
|
||||
### Migration
|
||||
|
||||
**File**: `api/alembic/versions/002_add_thumbnail_key.py`
|
||||
|
||||
```
|
||||
upgrade: ALTER TABLE images ADD COLUMN thumbnail_key VARCHAR(70);
|
||||
downgrade: ALTER TABLE images DROP COLUMN thumbnail_key;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ORM model change: `Image`
|
||||
|
||||
`api/app/models.py` — `Image` class gains one field:
|
||||
|
||||
```
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New module: `api/app/thumbnail.py`
|
||||
|
||||
Contains the thumbnail generation logic. Not a model, but documented here because
|
||||
it defines the thumbnail's shape:
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Output format | WebP |
|
||||
| Max dimension (longest side) | 400 px |
|
||||
| Aspect ratio | Preserved (never upscaled) |
|
||||
| Source formats supported | JPEG, PNG, GIF (frame 0), WebP |
|
||||
| Key signature | `async def generate_thumbnail(data: bytes, mime_type: str) -> bytes` |
|
||||
|
||||
---
|
||||
|
||||
## API response shape change
|
||||
|
||||
`_image_to_dict()` in `api/app/routers/images.py` adds `"thumbnail_key"` to its
|
||||
output so the UI can determine whether a thumbnail is available:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"hash": "...",
|
||||
"thumbnail_key": "abc123...-thumb", ← new (null if no thumbnail)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The UI uses the presence of `thumbnail_key` to decide whether to call
|
||||
`/api/v1/images/{id}/thumbnail` (with thumbnail) or fall back to
|
||||
`/api/v1/images/{id}/file` (without). In practice the endpoint itself
|
||||
handles the fallback, so the UI can always call `/thumbnail`.
|
||||
|
||||
---
|
||||
|
||||
## Storage objects per image (after this feature)
|
||||
|
||||
| Object | Key | Format | Created at |
|
||||
|--------|-----|--------|-----------|
|
||||
| Original | `{sha256_hash}` | Original mime_type | Upload |
|
||||
| Thumbnail | `{sha256_hash}-thumb` | `image/webp` | Upload (same request) |
|
||||
|
||||
Thumbnail object is deleted alongside original on image deletion.
|
||||
246
specs/003-upload-thumbnails/plan.md
Normal file
246
specs/003-upload-thumbnails/plan.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Implementation Plan: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/003-upload-thumbnails/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
When an image is uploaded, generate a WebP thumbnail (longest side ≤ 400 px,
|
||||
aspect ratio preserved) and store it in S3 alongside the original. Add a
|
||||
`GET /api/v1/images/{id}/thumbnail` endpoint that serves the thumbnail (or falls
|
||||
back to the original for images that pre-date the feature). The Angular library
|
||||
grid switches from `/file` to `/thumbnail`. The detail page is unchanged.
|
||||
|
||||
Changes span: a new Pillow dependency, a new `thumbnail.py` utility module, one
|
||||
Alembic migration, the upload and delete routes, a new thumbnail serve route, and
|
||||
the Angular image service and library component.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
|
||||
**Primary Dependencies**: FastAPI, Pillow (new — thumbnail generation), aiobotocore,
|
||||
SQLAlchemy 2.x async, Alembic, Angular
|
||||
**Storage**: S3-compatible object storage via `StorageBackend.put()` and `.get()`;
|
||||
thumbnails stored at key `{sha256_hash}-thumb` in the same bucket
|
||||
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
|
||||
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
|
||||
**Project Type**: Web application — FastAPI API + Angular SPA
|
||||
**Performance Goals**: 20-image grid transfers ≥ 80% less data than full-size;
|
||||
first page of 1,000-image library loads in under 2 s
|
||||
**Constraints**: Thumbnail generation is synchronous within the upload request;
|
||||
thumbnail failure must not block upload success; no backfill of existing images in v1
|
||||
**Scale/Scope**: Single-user personal application; upload frequency low
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|-----------|-------|--------|
|
||||
| §2.1 Separation of concerns | `thumbnail.py` owns resize logic; router owns orchestration; UI knows nothing about S3 keys | ✅ |
|
||||
| §2.2 Dependency direction | UI → API → Storage; thumbnail stored via `StorageBackend`; no upward imports | ✅ |
|
||||
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend.put()` and `.get()`; no raw S3 SDK calls in routes or `thumbnail.py` | ✅ |
|
||||
| §2.4 Auth abstraction | No change to auth flow | ✅ |
|
||||
| §2.5 DB abstraction | New `thumbnail_key` column accessed only through `ImageRepository`; migration added | ✅ |
|
||||
| §2.6 No speculative abstraction | `thumbnail.py` is a concrete module-level function; no interface added because one implementation exists | ✅ |
|
||||
| §3.1 API versioning | New route at `/api/v1/images/{id}/thumbnail` | ✅ |
|
||||
| §3.3 Error shape | `image_not_found` and `storage_error` codes used consistently | ✅ |
|
||||
| §4.2 Images immutable after upload | Thumbnail is generated at upload time only; never mutated | ✅ |
|
||||
| §4.3 Dedup by hash | Duplicate upload returns existing record including existing `thumbnail_key`; no re-generation | ✅ |
|
||||
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
|
||||
| §5.2 Test pyramid | Unit test for `thumbnail.py`; integration tests for new route + upload + delete | ✅ |
|
||||
| §5.3 Test colocation | API tests in `api/tests/`; Angular spec files colocated with components | ✅ |
|
||||
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
|
||||
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
|
||||
| §7.2 Env configuration | No new env vars; Pillow is a build dependency, not a runtime config | ✅ |
|
||||
| §8 Scope boundaries | Backfill of existing images, multiple thumbnail sizes, animated WebP — all deferred | ✅ |
|
||||
|
||||
**Post-design re-check**: All gates still pass after Phase 1 design.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/003-upload-thumbnails/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 decisions
|
||||
├── data-model.md # Schema and module changes
|
||||
├── contracts/
|
||||
│ └── api.md # New endpoint + changed response shapes
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
||||
```
|
||||
|
||||
### Files changed or created
|
||||
|
||||
```text
|
||||
api/
|
||||
├── pyproject.toml # Add Pillow dependency
|
||||
├── app/
|
||||
│ ├── thumbnail.py # NEW — generate_thumbnail()
|
||||
│ ├── models.py # Add thumbnail_key column to Image
|
||||
│ ├── repositories/
|
||||
│ │ └── image_repo.py # Pass thumbnail_key on create
|
||||
│ └── routers/
|
||||
│ └── images.py # Upload: generate+store thumbnail
|
||||
│ # Delete: remove thumbnail
|
||||
│ # New route: GET /images/{id}/thumbnail
|
||||
│ # _image_to_dict: add thumbnail_key field
|
||||
└── alembic/
|
||||
└── versions/
|
||||
└── 002_add_thumbnail_key.py # NEW migration
|
||||
|
||||
ui/
|
||||
└── src/
|
||||
└── app/
|
||||
├── services/
|
||||
│ └── image.service.ts # Add getThumbnailUrl(); add thumbnail_key to ImageRecord
|
||||
└── library/
|
||||
└── library.component.ts # Use getThumbnailUrl() for grid image src
|
||||
```
|
||||
|
||||
## Milestones
|
||||
|
||||
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
|
||||
> the failing test(s) first, confirm they fail, then implement until they pass.
|
||||
|
||||
---
|
||||
|
||||
### M1 — Thumbnail generation utility
|
||||
|
||||
**Goal**: A tested, self-contained function that produces a WebP thumbnail from
|
||||
raw image bytes.
|
||||
|
||||
**Deliverables**:
|
||||
- Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`
|
||||
- Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`:
|
||||
- Open image bytes with Pillow
|
||||
- Seek to frame 0 (handles animated GIFs)
|
||||
- Convert mode as needed for WebP output
|
||||
- Resize to fit within 400×400 using LANCZOS resampling (never upscale)
|
||||
- Encode as WebP quality 80 and return bytes
|
||||
- Unit tests in `api/tests/unit/test_thumbnail.py`:
|
||||
- `test_thumbnail_is_webp` — output starts with WebP magic bytes
|
||||
- `test_thumbnail_fits_within_400px` — both dimensions ≤ 400
|
||||
- `test_thumbnail_preserves_aspect_ratio` — ratio within 1% of original
|
||||
- `test_thumbnail_handles_gif_first_frame` — GIF input produces static WebP
|
||||
- `test_thumbnail_handles_png_with_alpha` — RGBA PNG produces valid WebP
|
||||
- `test_thumbnail_does_not_upscale` — 100×100 image stays ≤ 100×100
|
||||
|
||||
**Done criterion**: All unit tests pass; `ruff check api/` passes.
|
||||
|
||||
---
|
||||
|
||||
### M2 — Database migration
|
||||
|
||||
**Goal**: The `images` table has a nullable `thumbnail_key` column; the ORM model
|
||||
and repository reflect it.
|
||||
|
||||
**Deliverables**:
|
||||
- `api/alembic/versions/002_add_thumbnail_key.py` — `upgrade` adds
|
||||
`VARCHAR(70) NULLABLE` column; `downgrade` drops it
|
||||
- `api/app/models.py`: add `thumbnail_key: Mapped[str | None]` mapped to
|
||||
`String(70)`, `nullable=True`, `default=None`
|
||||
- `api/app/repositories/image_repo.py`: add `thumbnail_key: str | None = None`
|
||||
parameter to `create()`; persist it on the new `Image` instance
|
||||
|
||||
**Done criterion**: `alembic upgrade head` runs cleanly inside Docker; all
|
||||
existing 46 integration tests still pass (new column is nullable, no existing
|
||||
test breaks).
|
||||
|
||||
---
|
||||
|
||||
### M3 — Upload route: generate and store thumbnail
|
||||
|
||||
**Goal**: Every new upload generates a thumbnail; duplicates reuse the existing
|
||||
record; failures are tolerated without blocking the upload.
|
||||
|
||||
**TDD first** — new tests in `api/tests/integration/test_upload.py`:
|
||||
- `test_upload_returns_thumbnail_key` — upload response includes non-null
|
||||
`thumbnail_key` ending in `-thumb`
|
||||
- `test_duplicate_upload_reuses_thumbnail_key` — second upload of the same
|
||||
file returns the same `thumbnail_key` as the first
|
||||
- `test_upload_succeeds_when_thumbnail_fails` — patch `generate_thumbnail` to
|
||||
raise, upload returns 200/201 with `thumbnail_key: null`
|
||||
|
||||
**Implementation** in `api/app/routers/images.py` `upload_image()`:
|
||||
1. After `await storage.put(hash_hex, data, mime_type)`, attempt thumbnail generation
|
||||
and storage in a try/except; catch any exception and leave `thumbnail_key` as `None`
|
||||
2. Call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)`
|
||||
— `generate_thumbnail` is CPU-bound (Pillow); `asyncio.to_thread` runs it in the
|
||||
default thread pool executor so it does not block the async event loop
|
||||
3. Pass `thumbnail_key` to `image_repo.create()`
|
||||
4. Add `"thumbnail_key": image.thumbnail_key` to `_image_to_dict()`
|
||||
|
||||
**Done criterion**: New tests pass; all 46 existing tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M4 — New `GET /api/v1/images/{id}/thumbnail` endpoint
|
||||
|
||||
**Goal**: Clients fetch thumbnail content; falls back to original if no thumbnail exists.
|
||||
|
||||
**TDD first** — new tests in `api/tests/integration/test_serving.py`:
|
||||
- `test_thumbnail_returns_webp` — upload image, call `/thumbnail`, assert
|
||||
200, `content-type: image/webp`, ETag, `Cache-Control` with immutable
|
||||
- `test_thumbnail_fallback_returns_original` — set `thumbnail_key=None` on a
|
||||
record, call `/thumbnail`, assert 200 with original mime_type
|
||||
- `test_thumbnail_unknown_id_returns_404` — unknown UUID → 404 `image_not_found`
|
||||
|
||||
**Implementation**: new route `GET /images/{image_id}/thumbnail` in
|
||||
`api/app/routers/images.py` using `image.thumbnail_key or image.storage_key`
|
||||
to select the key, and `"image/webp" if image.thumbnail_key else image.mime_type`
|
||||
for the content type. Same `ETag` + `Cache-Control` headers as `/file`.
|
||||
|
||||
**Done criterion**: All new tests pass; all existing tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M5 — Delete route: remove thumbnail from storage
|
||||
|
||||
**Goal**: Deleting an image also removes its thumbnail; no orphaned objects left.
|
||||
|
||||
**TDD first** — new test in `api/tests/integration/test_delete.py`:
|
||||
- `test_delete_removes_thumbnail` — upload image, delete it, then verify
|
||||
`GET /images/{id}/thumbnail` returns 404
|
||||
|
||||
**Implementation** in `api/app/routers/images.py` `delete_image()`: after
|
||||
deleting the DB record and the original object, call `await storage.delete(image.thumbnail_key)`
|
||||
if `image.thumbnail_key` is not None.
|
||||
|
||||
**Done criterion**: New test passes; all existing delete tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M6 — UI: library grid uses thumbnail endpoint
|
||||
|
||||
**Goal**: Library grid fetches thumbnails instead of full-size originals; detail
|
||||
page is unchanged.
|
||||
|
||||
**Deliverables**:
|
||||
- `ui/src/app/services/image.service.ts`:
|
||||
- Add `thumbnail_key: string | null` to `ImageRecord` interface
|
||||
- Add `getThumbnailUrl(id: string): string` returning `/api/v1/images/${id}/thumbnail`
|
||||
- `ui/src/app/library/library.component.ts` + template: replace
|
||||
`getFileUrl(image.id)` with `getThumbnailUrl(image.id)` for grid `<img src>`
|
||||
- Update Angular spec files: add `thumbnail_key: null` to all `ImageRecord`
|
||||
mock objects
|
||||
- Verify `ng test` passes and `ng build` succeeds
|
||||
|
||||
**Done criterion**: Angular build clean; all Angular tests pass; library grid
|
||||
`<img>` elements reference `/thumbnail` not `/file`.
|
||||
|
||||
## Post-design Constitution Re-check
|
||||
|
||||
| Principle | Verdict |
|
||||
|-----------|---------|
|
||||
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend`; `thumbnail.py` never touches S3 directly | ✅ |
|
||||
| §2.5 DB abstraction | `thumbnail_key` persisted only through `ImageRepository.create()` | ✅ |
|
||||
| §2.6 No speculative abstraction | One concrete function; no interface | ✅ |
|
||||
| §4.2 Immutability | Thumbnail written once at upload; never mutated | ✅ |
|
||||
| §5.1 TDD | Failing tests written before each milestone's implementation | ✅ |
|
||||
|
||||
All gates pass. Feature is ready for `/speckit-tasks`.
|
||||
130
specs/003-upload-thumbnails/research.md
Normal file
130
specs/003-upload-thumbnails/research.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Research: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
## Decision 1: Image processing library
|
||||
|
||||
**Decision**: Add `Pillow` as a runtime dependency to the API.
|
||||
|
||||
**Rationale**: Pillow is the standard Python image processing library. It supports
|
||||
reading JPEG, PNG, GIF (frame extraction), and WebP, and can encode output as WebP.
|
||||
It handles aspect-ratio-preserving resize natively via `Image.thumbnail()`. No
|
||||
other dependency is needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `wand` (ImageMagick binding): More powerful but much heavier; overkill for a
|
||||
fixed-size resize operation.
|
||||
- `opencv-python`: ML-focused, large binary; not justified for simple resize.
|
||||
- Pure `aiobotocore` + external service: Adds operational complexity with no benefit
|
||||
over a local library for a single-user app.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Thumbnail dimensions and format
|
||||
|
||||
**Decision**: Longest side ≤ 400 px, WebP output, aspect ratio preserved. This
|
||||
matches FR-003 and FR-004 exactly and the user's stated preference.
|
||||
|
||||
**Rationale**: WebP produces smaller files than JPEG/PNG at equivalent visual quality.
|
||||
400 px covers a typical grid thumbnail at 1× and 2× display density without being
|
||||
oversized. Pillow's `Image.thumbnail((400, 400))` implements this constraint directly
|
||||
(it shrinks to fit within the bounding box, never upscaling).
|
||||
|
||||
**Alternatives considered**:
|
||||
- JPEG thumbnails: Larger file sizes; no alpha channel support.
|
||||
- Multiple sizes: Out of scope for v1 per spec Assumptions.
|
||||
- On-demand resize: Rejected by user in favour of pre-generation.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: Thumbnail storage key convention
|
||||
|
||||
**Decision**: `{sha256_hash}-thumb` (e.g., the 64-char hash hex string + literal
|
||||
`-thumb`, giving a 70-char key). Stored under the same S3 bucket as originals.
|
||||
|
||||
**Rationale**: Deterministic from the image hash — no new random state needed and the
|
||||
key can always be reconstructed from the `Image.hash` field. The `-thumb` suffix
|
||||
clearly distinguishes it from the original key. Fits within a `String(70)` column.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate bucket for thumbnails: More complex bucket policy management with no benefit
|
||||
for a single-user app.
|
||||
- UUID-based key: Non-deterministic; requires an extra DB round-trip to look up.
|
||||
- `{hash}/thumb.webp` (path prefix): Works, but adds key parsing complexity for no gain.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: Database schema change
|
||||
|
||||
**Decision**: Add a nullable `thumbnail_key: String(70)` column to the `images`
|
||||
table. `NULL` means no thumbnail exists (either generation failed or the image
|
||||
pre-dates this feature). Add a new Alembic migration `002_add_thumbnail_key.py`.
|
||||
|
||||
**Rationale**: Explicitly tracking the thumbnail key in the DB makes the "does a
|
||||
thumbnail exist?" question a simple `IS NOT NULL` check rather than an S3 head
|
||||
request. Also allows the delete route to skip the thumbnail delete if the column
|
||||
is NULL, avoiding a storage error for legacy images.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Derive key at runtime from `image.hash + "-thumb"` without a DB column: Simpler but
|
||||
means no way to distinguish "thumbnail was generated" from "thumbnail was never
|
||||
attempted", and delete would need a conditional S3 head request.
|
||||
- Separate `thumbnails` table: Over-engineered; one thumbnail per image with no
|
||||
additional attributes doesn't warrant its own table.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: Where thumbnail generation lives in the code
|
||||
|
||||
**Decision**: A standalone async function `generate_thumbnail(data: bytes, mime_type: str) -> bytes`
|
||||
in a new module `api/app/thumbnail.py`. Called from the upload route after the original
|
||||
is stored, before the Image record is created.
|
||||
|
||||
**Rationale**: Keeps the thumbnail logic self-contained and independently testable.
|
||||
The upload route calls it but doesn't own it. Constitution §2.6 allows concrete
|
||||
functions when no second implementation is needed — no abstract interface is warranted.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Method on `StorageBackend`: Wrong layer; storage knows nothing about image content.
|
||||
- Inline in the upload route: Makes the route harder to test and read.
|
||||
- A `ThumbnailService` class: No justification for a class when a module-level function suffices.
|
||||
|
||||
---
|
||||
|
||||
## Decision 6: Failure handling during upload
|
||||
|
||||
**Decision**: If `generate_thumbnail()` raises, log the exception, set `thumbnail_key`
|
||||
to `NULL` on the Image record, and continue. The upload response succeeds. The
|
||||
`GET /api/v1/images/{id}/thumbnail` endpoint falls back to the original when
|
||||
`thumbnail_key` is NULL (FR-009).
|
||||
|
||||
**Rationale**: A thumbnail failure should not block the upload — the user still gets
|
||||
their image in the library. The fallback in the thumbnail endpoint ensures the grid
|
||||
still renders something.
|
||||
|
||||
---
|
||||
|
||||
## Decision 7: Thumbnail endpoint response
|
||||
|
||||
**Decision**: `GET /api/v1/images/{id}/thumbnail` follows the same pattern as
|
||||
`GET /api/v1/images/{id}/file`:
|
||||
- Returns `200` with binary content, `Content-Type: image/webp` (or original
|
||||
`mime_type` when falling back to original), `ETag`, and
|
||||
`Cache-Control: public, max-age=31536000, immutable`.
|
||||
- Returns `404` with `{"detail": "...", "code": "image_not_found"}` if the image
|
||||
does not exist.
|
||||
- Falls back to the original when `thumbnail_key IS NULL`.
|
||||
|
||||
**Rationale**: Consistent with the existing `/file` endpoint pattern established in
|
||||
feature 002. The UI only needs to know one URL per image for the grid.
|
||||
|
||||
---
|
||||
|
||||
## Decision 8: GIF handling
|
||||
|
||||
**Decision**: For GIF uploads, `generate_thumbnail()` extracts frame 0 via
|
||||
`Image.seek(0)` before resizing. The output is always WebP (static, not animated).
|
||||
|
||||
**Rationale**: Matches spec assumption: "Animated GIF thumbnails capture only the
|
||||
first frame; animation is not preserved in the thumbnail." Pillow supports this
|
||||
with `im.seek(0)`.
|
||||
180
specs/003-upload-thumbnails/spec.md
Normal file
180
specs/003-upload-thumbnails/spec.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Feature Specification: Upload Thumbnails
|
||||
|
||||
**Feature Branch**: `003-upload-thumbnails`
|
||||
**Created**: 2026-05-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "When users load the UI, full size images are fetched, which may cause considerable load time when there are a lot of images -- a grid of 20 images could silently pull several hundred megabytes. We will solve this by pre-generating thumbnails on upload: when an image is uploaded, immediately produce one (or a few) fixed-size thumbnail variants and store them alongside the original. The library always fetches the thumbnail key, the detail page fetches the original key. Zero resize cost at serve time. A single fixed-size re-encoded as WebP for smaller bytes will cover the grid view."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Fast Library Load (Priority: P1)
|
||||
|
||||
A user opens the application or runs a tag-filtered search. The image grid
|
||||
loads quickly even when the library contains many images, because each grid
|
||||
cell fetches a compact thumbnail rather than the full-size original.
|
||||
|
||||
**Why this priority**: This is the core motivation. A grid of 20 images today
|
||||
could pull hundreds of megabytes; thumbnails bring that down to a few
|
||||
megabytes, making the library usable on slow or metered connections.
|
||||
|
||||
**Independent Test**: Upload 20 images of varying sizes (including some near
|
||||
the 50 MB limit). Open the library. Measure total bytes transferred while the
|
||||
grid loads. Compare against loading the same library before this feature.
|
||||
Verify the grid renders fully and that each thumbnail is visually recognisable
|
||||
as the correct image.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a library with multiple images, **When** the user opens the
|
||||
library page, **Then** each grid cell displays a thumbnail that is visually
|
||||
recognisable as its image, and the total data transferred to render the
|
||||
full grid is substantially less than the sum of the original file sizes.
|
||||
|
||||
2. **Given** a user applies one or more tag filters, **When** the filtered
|
||||
results are displayed, **Then** thumbnails are shown for all matching
|
||||
images with the same reduced data footprint.
|
||||
|
||||
3. **Given** a library with images of mixed types (JPEG, PNG, GIF, WebP),
|
||||
**When** the grid loads, **Then** thumbnails for all types display
|
||||
correctly.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Full-Size Image on Detail Page (Priority: P1)
|
||||
|
||||
A user clicks an image in the library grid to open its detail page. The
|
||||
full-size original is displayed, not the thumbnail. The experience is
|
||||
unchanged from before this feature.
|
||||
|
||||
**Why this priority**: The detail page is where the user inspects or copies
|
||||
an image; showing the thumbnail there would degrade the product's core value.
|
||||
|
||||
**Independent Test**: Open any image's detail page. Verify the image
|
||||
displayed matches the original resolution and file size, not the thumbnail
|
||||
dimensions.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user clicks an image thumbnail in the library, **When** the
|
||||
detail page loads, **Then** the full-size original image is displayed at
|
||||
its native resolution.
|
||||
|
||||
2. **Given** the user navigates directly to an image's detail URL, **When**
|
||||
the page loads, **Then** the full-size original is displayed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1)
|
||||
|
||||
A user uploads a new image. Without any additional action, a thumbnail is
|
||||
available immediately. There is no separate step or explicit request to
|
||||
generate a thumbnail.
|
||||
|
||||
**Why this priority**: The value of the feature depends entirely on thumbnails
|
||||
being present for every image. Manual generation or lazy generation would
|
||||
create inconsistencies in the grid.
|
||||
|
||||
**Independent Test**: Upload a new image. Immediately open the library. Verify
|
||||
the new image's thumbnail appears in the grid without any extra action.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user uploads a supported image, **When** the upload
|
||||
completes, **Then** a thumbnail is available and appears correctly in
|
||||
the library grid.
|
||||
|
||||
2. **Given** the user uploads a duplicate image (already in the library),
|
||||
**When** the upload completes, **Then** no redundant thumbnail is
|
||||
generated — the existing thumbnail is reused.
|
||||
|
||||
3. **Given** the user uploads an image at or near the maximum supported
|
||||
file size (50 MB), **When** the upload completes, **Then** the thumbnail
|
||||
is generated successfully and the upload response time remains acceptable.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when thumbnail generation fails during upload? → The upload
|
||||
still succeeds and the original image is stored; a fallback to the original
|
||||
is shown in the grid, or the item is hidden until the thumbnail is available
|
||||
(assumption: fall back to original rather than silently drop the image).
|
||||
- What happens when an image is deleted? → Both the original and its thumbnail
|
||||
are removed from storage.
|
||||
- What happens with existing images that were uploaded before this feature? →
|
||||
Those images have no pre-generated thumbnail; the grid falls back to the
|
||||
original for those entries until a backfill is performed (backfill is out of
|
||||
scope for v1 of this feature).
|
||||
- What happens with animated GIFs? → A static thumbnail is generated from the
|
||||
first frame.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST generate a thumbnail for every newly uploaded
|
||||
image as part of the upload operation, before the upload response is
|
||||
returned to the caller.
|
||||
- **FR-002**: Thumbnails MUST be stored in the same object storage as the
|
||||
original, addressable by a distinct key derived from the image.
|
||||
- **FR-003**: The thumbnail MUST be encoded as WebP regardless of the original
|
||||
image format.
|
||||
- **FR-004**: The thumbnail MUST fit within a fixed maximum dimension on its
|
||||
longest side, preserving the original aspect ratio; no dimension of the
|
||||
thumbnail MAY exceed 400 pixels.
|
||||
- **FR-005**: The library grid view MUST fetch and display thumbnails instead
|
||||
of original images.
|
||||
- **FR-006**: The image detail view MUST continue to fetch and display the
|
||||
full-size original.
|
||||
- **FR-007**: When a duplicate image is uploaded, the thumbnail MUST NOT be
|
||||
regenerated or re-stored; the existing thumbnail is reused.
|
||||
- **FR-008**: When an image is deleted, its thumbnail MUST also be deleted
|
||||
from storage.
|
||||
- **FR-009**: If thumbnail generation fails during upload, the upload MUST
|
||||
still succeed; the system MUST fall back to serving the original image in
|
||||
the grid for that entry.
|
||||
- **FR-010**: The API MUST expose a way for clients to retrieve the thumbnail
|
||||
content for a given image, distinct from the full-size content endpoint.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Image**: Gains a new optional attribute indicating whether a thumbnail is
|
||||
available and the key under which the thumbnail is stored.
|
||||
- **Thumbnail**: A derived, smaller representation of an Image. Key
|
||||
attributes: storage key, dimensions (width × height), format (WebP),
|
||||
relationship to its source Image.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The total data transferred to render a 20-image library grid is
|
||||
reduced by at least 80% compared to fetching full-size originals for the
|
||||
same images.
|
||||
- **SC-002**: The library grid's first page loads in under 2 seconds on a
|
||||
local network connection for a library of 1,000 images, with thumbnails
|
||||
visible without a second load.
|
||||
- **SC-003**: Thumbnails are available immediately after upload completes —
|
||||
no polling or manual refresh is required.
|
||||
- **SC-004**: The detail page continues to show the full-size original; no
|
||||
regression in detail-page image quality is introduced.
|
||||
- **SC-005**: Deleting an image removes both the original and its thumbnail;
|
||||
no orphaned thumbnail objects remain in storage after deletion.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A single thumbnail size (longest side ≤ 400 px, WebP) is sufficient for
|
||||
the library grid view in v1. Additional sizes or formats are out of scope.
|
||||
- Thumbnail generation happens synchronously during the upload request. Async
|
||||
background processing is not required for v1.
|
||||
- Existing images uploaded before this feature are not automatically
|
||||
backfilled with thumbnails in v1; the grid falls back to the original for
|
||||
those entries.
|
||||
- Animated GIF thumbnails capture only the first frame; animation is not
|
||||
preserved in the thumbnail.
|
||||
- The thumbnail storage key is derived deterministically from the image's
|
||||
existing content hash, so no additional database column is strictly required
|
||||
to locate it — however the Image record will track thumbnail availability
|
||||
for correctness.
|
||||
- No change is required to tag management, duplicate detection, or any other
|
||||
upload behaviour beyond adding thumbnail generation.
|
||||
186
specs/003-upload-thumbnails/tasks.md
Normal file
186
specs/003-upload-thumbnails/tasks.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Tasks: Upload Thumbnails
|
||||
|
||||
**Input**: Design documents from `specs/003-upload-thumbnails/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
|
||||
|
||||
**TDD**: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.
|
||||
|
||||
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [X] T001 Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`; rebuild the Docker API image (`docker compose build api`) so Pillow is available inside the container for all subsequent test runs
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Thumbnail generation logic and the DB schema change that all three user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: All tasks in this phase must complete before any user story work begins.
|
||||
|
||||
### Tests for thumbnail utility (write FIRST — must FAIL before T005) ⚠️
|
||||
|
||||
- [X] T002 Write 6 unit tests in `api/tests/unit/test_thumbnail.py` — `test_thumbnail_is_webp` (output begins with WebP magic bytes `RIFF...WEBP`), `test_thumbnail_fits_within_400px` (both dimensions ≤ 400), `test_thumbnail_preserves_aspect_ratio` (ratio within 1% of original), `test_thumbnail_handles_gif_first_frame` (GIF input → static WebP, no animation), `test_thumbnail_handles_png_with_alpha` (RGBA PNG → valid WebP output), `test_thumbnail_does_not_upscale` (100×100 input stays ≤ 100×100); to confirm the TDD red state, first create an empty stub `api/app/thumbnail.py` (so pytest can collect tests), then run `pytest api/tests/unit/test_thumbnail.py` and confirm all 6 **fail** with assertion errors (not import errors)
|
||||
|
||||
### Thumbnail utility implementation
|
||||
|
||||
- [X] T003 Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`: open bytes with `PIL.Image.open(BytesIO(data))`, call `.seek(0)` to target frame 0 (GIF support), convert mode to RGB or RGBA as needed for WebP, call `.thumbnail((400, 400), PIL.Image.LANCZOS)` (never upscales), save to a `BytesIO` buffer as WebP quality=80, return bytes; run `pytest api/tests/unit/test_thumbnail.py` and confirm all 6 pass
|
||||
|
||||
### Database migration
|
||||
|
||||
- [X] T004 [P] Create `api/alembic/versions/002_add_thumbnail_key.py` with `upgrade()` calling `op.add_column("images", sa.Column("thumbnail_key", sa.String(70), nullable=True))` and `downgrade()` calling `op.drop_column("images", "thumbnail_key")`; set `revision="002"`, `down_revision="001"`
|
||||
- [X] T005 [P] Add `thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)` to the `Image` class in `api/app/models.py`
|
||||
- [X] T006 Add `thumbnail_key: str | None = None` keyword argument to `ImageRepository.create()` in `api/app/repositories/image_repo.py`; include it in the `Image(...)` constructor call; run `docker compose run --rm api alembic upgrade head` inside the container and confirm migration applies cleanly; run `pytest api/` to confirm all 46 existing tests still pass
|
||||
|
||||
**Checkpoint**: Pillow available, thumbnail.py works, schema migrated, all existing tests green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1) 🎯
|
||||
|
||||
**Goal**: Every new upload triggers thumbnail generation and storage as part of the same request. No extra step required from the user.
|
||||
|
||||
**Independent Test**: Upload any supported image. Immediately check the upload response — it includes a non-null `thumbnail_key`. Call `GET /api/v1/images/{id}/thumbnail` — it returns 200 with `content-type: image/webp`.
|
||||
|
||||
### Tests for User Story 3 (write FIRST — must FAIL before T008) ⚠️
|
||||
|
||||
- [X] T007 [US3] Add three tests to `api/tests/integration/test_upload.py`: `test_upload_returns_thumbnail_key` (upload a JPEG/PNG/WebP, assert response JSON contains `thumbnail_key` ending in `-thumb`), `test_duplicate_upload_reuses_thumbnail_key` (upload same file twice, assert both responses have equal, non-null `thumbnail_key`), and `test_upload_succeeds_when_thumbnail_fails` (patch `generate_thumbnail` to raise an exception, upload an image, assert response is 200/201 with `thumbnail_key: null` — upload must not be blocked by thumbnail failure); run `pytest api/tests/integration/test_upload.py` and confirm all three new tests **fail**
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T008 [US3] In `api/app/routers/images.py` `upload_image()`: import `asyncio` and `generate_thumbnail` from `app.thumbnail`; after `await storage.put(hash_hex, data, mime_type)`, wrap thumbnail generation in a try/except — call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)` (runs CPU-bound Pillow work off the async event loop), store result via `await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")`, set `thumbnail_key = f"{hash_hex}-thumb"`; on any exception log a warning and leave `thumbnail_key = None`; pass `thumbnail_key=thumbnail_key` to `image_repo.create()`
|
||||
- [X] T009 [US3] Add `"thumbnail_key": image.thumbnail_key` to the dict returned by `_image_to_dict()` in `api/app/routers/images.py`
|
||||
- [X] T010 [US3] Run `pytest api/tests/integration/test_upload.py` to confirm both new tests pass; then run `pytest api/` to confirm no regressions
|
||||
|
||||
**Checkpoint**: Upload generates and stores a thumbnail. Duplicate uploads reuse the existing thumbnail. `thumbnail_key` appears in all image metadata responses.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 1 — Fast Library Load (Priority: P1)
|
||||
|
||||
**Goal**: The library grid fetches compact WebP thumbnails instead of full-size originals, dramatically reducing page-load bandwidth.
|
||||
|
||||
**Independent Test**: Open the library in a browser with DevTools network tab open. All grid `<img>` elements request `/api/v1/images/{id}/thumbnail`. Total bytes transferred for a 20-image grid is a small fraction of what the originals would cost.
|
||||
|
||||
### Tests for User Story 1 (write FIRST — must FAIL before T013) ⚠️
|
||||
|
||||
- [X] T011 [US1] Add `test_thumbnail_returns_webp` (upload image, GET `/thumbnail`, assert 200, `content-type: image/webp`, ETag header matches `f'"{image_hash}"'`, `"immutable"` in `cache-control`, non-empty content), `test_thumbnail_fallback_returns_original` (manually set `thumbnail_key=None` on a DB record via the session fixture, GET `/thumbnail`, assert 200 with original `mime_type` in content-type), and `test_thumbnail_unknown_id_returns_404` (unknown UUID, assert 404 `image_not_found`) to `api/tests/integration/test_serving.py`; run and confirm all three **fail**
|
||||
- [X] T012 [P] [US1] In `ui/src/app/services/image.service.ts`: add `thumbnail_key: string | null` field to the `ImageRecord` interface; add `getThumbnailUrl(id: string): string { return \`${this.base}/images/${id}/thumbnail\`; }` method to `ImageService`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Add `GET /api/v1/images/{image_id}/thumbnail` route to `api/app/routers/images.py`: look up image (404 if missing), select `key = image.thumbnail_key or image.storage_key` and `media_type = "image/webp" if image.thumbnail_key else image.mime_type`, call `await storage.get(key)` in a try/except (500 `storage_error` on failure), return `Response(content=data, media_type=media_type, headers={"ETag": f'"{image.hash}"', "Cache-Control": "public, max-age=31536000, immutable"})`
|
||||
- [X] T014 [US1] In `ui/src/app/library/library.component.ts` and its HTML template: replace every use of `imageService.getFileUrl(image.id)` (or equivalent) with `imageService.getThumbnailUrl(image.id)` for grid cell `<img src>` bindings
|
||||
- [X] T015 [US1] Add `thumbnail_key: null` to every `ImageRecord` mock/stub object in `ui/src/app/services/image.service.spec.ts`, `ui/src/app/library/library.component.spec.ts`, `ui/src/app/detail/detail.component.spec.ts`, and `ui/src/app/upload/upload.component.spec.ts`
|
||||
- [X] T016 [US1] Run `pytest api/tests/integration/test_serving.py` to confirm all three new thumbnail tests pass and no existing serving tests regress
|
||||
|
||||
**Checkpoint**: `GET /api/v1/images/{id}/thumbnail` serves WebP with caching headers. Falls back to original for legacy images. Library grid `<img>` elements all use the thumbnail endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 2 — Full-Size Image on Detail Page (Priority: P1)
|
||||
|
||||
**Goal**: The detail page continues to display the full-size original. No regression introduced by the thumbnail work.
|
||||
|
||||
**Independent Test**: Navigate to any image detail page. The image displayed is full-resolution. Browser DevTools shows the detail `<img>` requests `/api/v1/images/{id}/file`, not `/thumbnail`.
|
||||
|
||||
### Verification for User Story 2
|
||||
|
||||
- [X] T017 [US2] Confirm `ui/src/app/detail/detail.component.ts` still calls `imageService.getFileUrl(image.id)` (not `getThumbnailUrl`) for its `<img src>` — no code change expected; if the file was accidentally updated in T014 or T015, revert the detail component to `getFileUrl`
|
||||
- [X] T018 [US2] Run `ng test` (inside the UI container or locally) and confirm all Angular unit tests pass including the detail component spec; run `ng build` to confirm the Angular build succeeds
|
||||
|
||||
**Checkpoint**: Detail page verified unchanged. Angular build and tests clean.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Delete cleanup, final test run, linting.
|
||||
|
||||
### Delete thumbnail on image deletion
|
||||
|
||||
- [X] T019 Write `test_delete_removes_thumbnail` in `api/tests/integration/test_delete.py`: upload an image, delete it, then `GET /api/v1/images/{id}/thumbnail` and assert 404; run and confirm it **fails** (currently delete does not remove the thumbnail object)
|
||||
- [X] T020 In `api/app/routers/images.py` `delete_image()`: capture `thumbnail_key = image.thumbnail_key` before `image_repo.delete(image)`; after deleting the original via `await storage.delete(storage_key)`, add `if thumbnail_key: await storage.delete(thumbnail_key)`; run `pytest api/tests/integration/test_delete.py` to confirm new test and all existing delete tests pass
|
||||
|
||||
### Final validation
|
||||
|
||||
- [X] T021 [P] Run `~/.local/bin/ruff check api/app/thumbnail.py api/app/routers/images.py api/app/models.py api/app/repositories/image_repo.py api/tests/unit/test_thumbnail.py` and fix any lint issues in the changed files
|
||||
- [X] T022 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: 46 existing + ~10 new = ~56 total)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (Pillow must be installed before tests can import it)
|
||||
- **US3 (Phase 3)**: Depends on Phase 2 complete (T002–T006 all done)
|
||||
- **US1 (Phase 4)**: Depends on Phase 3 complete (upload must set `thumbnail_key` before the endpoint can serve one)
|
||||
- **US2 (Phase 5)**: Depends on Phase 4 complete (Angular changes in T014/T015 must be done before verifying no regression)
|
||||
- **Polish (Phase 6)**: Depends on Phases 3, 4, and 5 complete
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- T002 (write failing tests) MUST precede T003 (implement thumbnail.py)
|
||||
- T004 and T005 can run in parallel (different files)
|
||||
- T006 (repo change) depends on T005 (model must compile first)
|
||||
- T007 (write failing upload tests) MUST precede T008 (implement upload change)
|
||||
- T011 (write failing serving tests) and T012 (UI service) can run in parallel
|
||||
- T011 MUST precede T013 (implement thumbnail route)
|
||||
- T019 (write failing delete test) MUST precede T020 (implement delete cleanup)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 (Foundational)
|
||||
|
||||
```bash
|
||||
# After T003 is done, run T004 and T005 together:
|
||||
Task: "Create 002_add_thumbnail_key.py migration in api/alembic/versions/"
|
||||
Task: "Add thumbnail_key column to Image ORM in api/app/models.py"
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 4 (US1)
|
||||
|
||||
```bash
|
||||
# T011 and T012 touch different layers — run together:
|
||||
Task: "Write 3 failing thumbnail serving tests in api/tests/integration/test_serving.py"
|
||||
Task: "Add getThumbnailUrl() and thumbnail_key field to ui/src/app/services/image.service.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (All three user stories — tightly coupled)
|
||||
|
||||
All three user stories are P1 and interdependent: US3 (generation) enables US1 (grid) which proves US2 (detail unchanged) by contrast. Complete all phases in order.
|
||||
|
||||
1. Phase 1: T001 (Pillow setup)
|
||||
2. Phase 2: T002–T006 (core infrastructure)
|
||||
3. Phase 3: T007–T010 (upload generates thumbnail)
|
||||
4. Phase 4: T011–T016 (thumbnail endpoint + UI)
|
||||
5. Phase 5: T017–T018 (detail page verification)
|
||||
6. Phase 6: T019–T022 (delete cleanup + polish)
|
||||
7. **STOP and VALIDATE**: Open library in browser; DevTools shows `/thumbnail` requests; bandwidth used is a fraction of original file sizes
|
||||
|
||||
### Total tasks: 22 (T001–T022)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and have no mutual dependencies within their phase
|
||||
- T002 must be run **before** T003 and confirmed failing — this is the TDD red step
|
||||
- T007, T011, T019 are all "write failing test" steps — confirm failure before implementing
|
||||
- `thumbnail_key` in the API response is informational; the UI always calls `/thumbnail` and lets the endpoint handle the fallback — no client-side conditional logic needed
|
||||
- Existing images (pre-dating this feature) will have `thumbnail_key: null`; the `/thumbnail` endpoint serves their original transparently
|
||||
- The backfill migration for existing images is explicitly out of scope for this feature
|
||||
34
specs/004-jwt-bearer-auth/checklists/requirements.md
Normal file
34
specs/004-jwt-bearer-auth/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: JWT Bearer Token Authentication
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All items pass. Spec is ready for `/speckit-plan`.
|
||||
120
specs/004-jwt-bearer-auth/contracts/api.md
Normal file
120
specs/004-jwt-bearer-auth/contracts/api.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# API Contracts: JWT Bearer Token Authentication
|
||||
|
||||
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
|
||||
|
||||
All routes remain under `/api/v1/`. Error responses use the existing envelope:
|
||||
`{ "detail": "<human message>", "code": "<machine code>" }`.
|
||||
|
||||
---
|
||||
|
||||
## New Endpoint
|
||||
|
||||
### `POST /api/v1/auth/token`
|
||||
|
||||
Issues a bearer token for the owner after verifying credentials.
|
||||
|
||||
**Request**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "<string>",
|
||||
"password": "<string>"
|
||||
}
|
||||
```
|
||||
|
||||
Both fields are required. A missing or empty field returns `422`.
|
||||
|
||||
**Success response** — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "<jwt-string>",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400
|
||||
}
|
||||
```
|
||||
|
||||
`expires_in` reflects the configured `JWT_EXPIRY_SECONDS` value.
|
||||
|
||||
**Failure responses**
|
||||
|
||||
| Status | Code | When |
|
||||
|---|---|---|
|
||||
| `401` | `invalid_credentials` | Username or password is wrong |
|
||||
| `422` | (FastAPI default) | Missing or malformed request body |
|
||||
|
||||
---
|
||||
|
||||
## Changed Endpoints — Access Control
|
||||
|
||||
The following endpoints now require a valid bearer token. Requests without
|
||||
a token, or with an invalid/expired token, receive a `401`.
|
||||
|
||||
| Method | Path | Was | Now |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/v1/images` | Public | **Protected** |
|
||||
| `DELETE` | `/api/v1/images/{id}` | Public | **Protected** |
|
||||
| `PATCH` | `/api/v1/images/{id}/tags` | Public | **Protected** |
|
||||
|
||||
**Bearer token transmission**
|
||||
|
||||
The client MUST include the token in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**401 response shape** (returned by all three protected endpoints when
|
||||
authentication fails):
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Authentication required",
|
||||
"code": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unchanged Endpoints — Remain Public
|
||||
|
||||
The following endpoints require no token and must continue to accept requests
|
||||
without an `Authorization` header:
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/images` | List / filter images |
|
||||
| `GET` | `/api/v1/images/{id}` | Get image metadata |
|
||||
| `GET` | `/api/v1/images/{id}/file` | Serve original image |
|
||||
| `GET` | `/api/v1/images/{id}/thumbnail` | Serve image thumbnail |
|
||||
| `GET` | `/api/v1/tags` | List / search tags |
|
||||
| `GET` | `/api/v1/health` | Health check |
|
||||
|
||||
Sending a token on these endpoints is harmless (the server ignores it) but
|
||||
is not required.
|
||||
|
||||
---
|
||||
|
||||
## Token Validation Rules
|
||||
|
||||
The API validates tokens using the following rules, in order:
|
||||
|
||||
1. The `Authorization` header value MUST begin with `Bearer ` (case-sensitive).
|
||||
2. The token MUST be a valid HS256-signed JWT (verified against `JWT_SECRET_KEY`).
|
||||
3. The `exp` claim MUST be in the future (at time of request receipt).
|
||||
4. Any failure in steps 1–3 returns `401 unauthorized`.
|
||||
|
||||
---
|
||||
|
||||
## UI Route Contracts
|
||||
|
||||
These are Angular SPA routes affected by this feature.
|
||||
|
||||
| Route | Guard | Behaviour |
|
||||
|---|---|---|
|
||||
| `/login` | None | Login form; redirects to `returnUrl` or `/` on success |
|
||||
| `/upload` | `authGuard` | Redirects to `/login?returnUrl=/upload` if not authenticated |
|
||||
| `/images/:id` | None | Always accessible; tag-edit and delete controls visible only when authenticated |
|
||||
| `/` | None | Always accessible (library) |
|
||||
187
specs/004-jwt-bearer-auth/data-model.md
Normal file
187
specs/004-jwt-bearer-auth/data-model.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Data Model: JWT Bearer Token Authentication
|
||||
|
||||
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## Database Changes
|
||||
|
||||
**None.** JWTs are stateless bearer tokens. The API validates them by
|
||||
cryptographic signature and embedded expiry claim on each request. No token
|
||||
storage, session table, or blocklist is introduced in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Schema (new env vars)
|
||||
|
||||
Four new environment variables are added to `api/app/config.py` and
|
||||
`.env.example`.
|
||||
|
||||
| Variable | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `JWT_SECRET_KEY` | `str` | Yes | — | HMAC-SHA256 signing secret. Must be a long random string; no default (startup fails if absent). |
|
||||
| `JWT_EXPIRY_SECONDS` | `int` | No | `86400` | Token lifetime in seconds (24 h). |
|
||||
| `OWNER_USERNAME` | `str` | Yes | — | Login username for the single owner account. |
|
||||
| `OWNER_PASSWORD` | `str` | Yes | — | Login password for the single owner account. |
|
||||
|
||||
These values are loaded via `pydantic-settings` (`BaseSettings`) alongside the
|
||||
existing database and S3 settings. `JWT_SECRET_KEY`, `OWNER_USERNAME`, and
|
||||
`OWNER_PASSWORD` have no defaults and will raise a validation error at startup
|
||||
if absent, providing a clear "misconfigured" failure rather than a silent
|
||||
security hole.
|
||||
|
||||
---
|
||||
|
||||
## Token Structure
|
||||
|
||||
A JWT issued by the login endpoint carries the following claims.
|
||||
|
||||
| Claim | Type | Value |
|
||||
|---|---|---|
|
||||
| `sub` | string | `"owner"` — fixed identifier for the single owner |
|
||||
| `iat` | integer | Unix epoch seconds at time of issuance |
|
||||
| `exp` | integer | `iat + JWT_EXPIRY_SECONDS` |
|
||||
|
||||
Algorithm: `HS256` (HMAC-SHA256). Secret: `JWT_SECRET_KEY` setting.
|
||||
|
||||
The token is opaque to the client. The Angular SPA stores it in
|
||||
`sessionStorage` and transmits it as `Authorization: Bearer <token>` on every
|
||||
request.
|
||||
|
||||
---
|
||||
|
||||
## Module and Interface Changes
|
||||
|
||||
### `api/app/auth/provider.py` — updated interface
|
||||
|
||||
The `get_identity()` method gains an `authorization` parameter — the raw value
|
||||
of the `Authorization` HTTP header (or `None` if the header is absent).
|
||||
|
||||
```
|
||||
AuthProvider (abstract)
|
||||
get_identity(authorization: str | None) -> Identity
|
||||
|
||||
Identity (dataclass)
|
||||
id: str
|
||||
anonymous: bool = True
|
||||
```
|
||||
|
||||
### `api/app/auth/noop.py` — no behavioural change
|
||||
|
||||
`NoOpAuthProvider.get_identity()` continues to return the static anonymous
|
||||
identity regardless of the `authorization` argument. The signature is updated
|
||||
to match the new interface.
|
||||
|
||||
### `api/app/auth/jwt_provider.py` — new module
|
||||
|
||||
```
|
||||
JWTAuthProvider (AuthProvider)
|
||||
__init__(secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str)
|
||||
|
||||
get_identity(authorization: str | None) -> Identity
|
||||
- Parses "Bearer <token>" from authorization header
|
||||
- Decodes and validates the JWT (signature + exp)
|
||||
- Returns Identity(id="owner", anonymous=False) on success
|
||||
- Raises HTTPException 401 with code "unauthorized" on any failure
|
||||
|
||||
create_token() -> str
|
||||
- Mints a new HS256 JWT with sub="owner", iat=now, exp=now+expiry_seconds
|
||||
- Returns the encoded token string
|
||||
|
||||
verify_credentials(username: str, password: str) -> bool
|
||||
- Compares username and password against OWNER_USERNAME / OWNER_PASSWORD
|
||||
- Uses secrets.compare_digest to prevent timing attacks
|
||||
- Returns True on match, False otherwise
|
||||
```
|
||||
|
||||
### `api/app/dependencies.py` — new `require_auth` dependency
|
||||
|
||||
```
|
||||
require_auth(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
auth: AuthProvider = Depends(get_auth)
|
||||
) -> Identity
|
||||
- Calls auth.get_identity(authorization)
|
||||
- Raises HTTPException 401 if identity.anonymous is True
|
||||
- Returns the Identity on success
|
||||
```
|
||||
|
||||
Protected routes inject `identity: Identity = Depends(require_auth)` and do
|
||||
not need to perform any additional auth checks — the dependency raises before
|
||||
the route body executes if authentication fails.
|
||||
|
||||
### `api/app/routers/auth.py` — new router
|
||||
|
||||
```
|
||||
POST /api/v1/auth/token
|
||||
Request body: LoginRequest { username: str, password: str }
|
||||
Success (200): TokenResponse { access_token: str, token_type: "bearer", expires_in: int }
|
||||
Failure (401): { detail: "Invalid credentials", code: "invalid_credentials" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Angular Module Changes
|
||||
|
||||
### `ui/src/app/auth/auth.service.ts` — new service
|
||||
|
||||
```
|
||||
AuthService
|
||||
TOKEN_KEY = 'auth_token' (sessionStorage key)
|
||||
|
||||
login(username: string, password: string): Observable<void>
|
||||
- POST /api/v1/auth/token
|
||||
- On success: stores access_token in sessionStorage, emits completion
|
||||
- On 401: propagates error for the component to handle
|
||||
|
||||
logout(): void
|
||||
- Removes token from sessionStorage
|
||||
|
||||
getToken(): string | null
|
||||
- Returns stored token or null
|
||||
|
||||
isAuthenticated(): boolean
|
||||
- Returns true if getToken() is non-null
|
||||
```
|
||||
|
||||
### `ui/src/app/auth/auth.interceptor.ts` — new functional interceptor
|
||||
|
||||
Attaches `Authorization: Bearer <token>` to every outbound `HttpRequest` if
|
||||
`AuthService.getToken()` returns a non-null value. Requests without a token
|
||||
are passed through unmodified.
|
||||
|
||||
### `ui/src/app/auth/auth.guard.ts` — new route guard
|
||||
|
||||
Functional `CanActivateFn`. If `AuthService.isAuthenticated()` is `false`,
|
||||
redirects to `/login?returnUrl=<current-url>`. Otherwise allows navigation.
|
||||
|
||||
### `ui/src/app/login/login.component.ts` — new component
|
||||
|
||||
Route: `/login`
|
||||
|
||||
```
|
||||
LoginComponent
|
||||
Fields: username (required), password (required)
|
||||
On submit:
|
||||
- Calls AuthService.login()
|
||||
- On success: navigates to returnUrl query param, or '/' if absent
|
||||
- On 401: displays inline error "Invalid username or password"
|
||||
- On other error: displays generic error message
|
||||
Shows loading state while request is in flight
|
||||
```
|
||||
|
||||
### `ui/src/app/detail/detail.component.ts` — updated
|
||||
|
||||
Injects `AuthService`. Hides tag-edit input and delete button when
|
||||
`auth.isAuthenticated()` is `false`. Shows them when authenticated.
|
||||
Read-only view (image display, tag chips) is always visible.
|
||||
|
||||
### `ui/src/app/app.routes.ts` — updated
|
||||
|
||||
`/upload` route gains `canActivate: [authGuard]`. `/login` route is added
|
||||
(unguarded). All other routes are unchanged.
|
||||
|
||||
### `ui/src/app/app.config.ts` — updated
|
||||
|
||||
`provideHttpClient()` becomes
|
||||
`provideHttpClient(withInterceptors([authInterceptor]))`.
|
||||
355
specs/004-jwt-bearer-auth/plan.md
Normal file
355
specs/004-jwt-bearer-auth/plan.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Implementation Plan: JWT Bearer Token Authentication
|
||||
|
||||
**Branch**: `004-jwt-bearer-auth` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/004-jwt-bearer-auth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement Phase 2 of the progressive auth plan (constitution §2.4): replace
|
||||
the no-op `AuthProvider` with a `JWTAuthProvider` that issues and validates
|
||||
HS256 bearer tokens. Upload, delete, and tag-update endpoints become
|
||||
protected; all read endpoints stay public. The Angular SPA gains a login page,
|
||||
a session-scoped token store, an HTTP interceptor, and a route guard for the
|
||||
upload page.
|
||||
|
||||
No database migration is required — tokens are stateless. A single new PyJWT
|
||||
dependency is added to the API. The `AuthProvider` interface gains a parameter
|
||||
so the JWT provider can access the `Authorization` header; `NoOpAuthProvider`
|
||||
is updated to match but its behaviour is unchanged.
|
||||
|
||||
Changes span: `api/app/config.py` (4 new settings), `api/app/auth/provider.py`
|
||||
(interface update), `api/app/auth/jwt_provider.py` (new), `api/app/routers/auth.py`
|
||||
(new), `api/app/dependencies.py` (new `require_auth` dependency), three route
|
||||
updates in `api/app/routers/images.py`, and on the UI side: a new `AuthService`,
|
||||
`AuthInterceptor`, `AuthGuard`, and `LoginComponent`, plus small updates to
|
||||
`app.routes.ts`, `app.config.ts`, and `detail.component.ts`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
|
||||
**Primary Dependencies**: FastAPI, PyJWT (new), pydantic-settings, Angular
|
||||
**Storage**: PostgreSQL (no schema changes); S3-compatible (no changes)
|
||||
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
|
||||
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
|
||||
**Project Type**: Web application — FastAPI API + Angular SPA
|
||||
**Performance Goals**: Login round-trip under 15 s on local network (well within
|
||||
reach; no database lookup, only in-memory credential comparison + JWT signing)
|
||||
**Constraints**: Stateless tokens — no server-side session storage; single owner
|
||||
account; no token revocation in v1
|
||||
**Scale/Scope**: Single-user personal application; auth is a deployment-time
|
||||
configuration concern, not a runtime management concern
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|---|---|---|
|
||||
| §2.1 Separation of concerns | JWT logic lives only in `JWTAuthProvider`; routes orchestrate; UI knows nothing about signing | ✅ |
|
||||
| §2.2 Dependency direction | UI → API only; no upward imports introduced | ✅ |
|
||||
| §2.3 Storage abstraction | No change to storage layer | ✅ |
|
||||
| §2.4 Auth abstraction | `JWTAuthProvider` is a second `AuthProvider` implementation — exactly the pattern §2.4 designed for | ✅ |
|
||||
| §2.5 DB abstraction | No DB changes; stateless JWTs require no session table | ✅ |
|
||||
| §2.6 No speculative abstraction | No new interfaces; only a second concrete implementation of an already-planned interface | ✅ |
|
||||
| §3.1 API versioning | New route at `/api/v1/auth/token` | ✅ |
|
||||
| §3.3 Error shape | `401` uses `{"detail": "...", "code": "unauthorized"}` / `"invalid_credentials"` | ✅ |
|
||||
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
|
||||
| §5.2 Test pyramid | Unit tests for JWT logic; integration tests for all changed routes | ✅ |
|
||||
| §5.3 Test colocation | API tests in `api/tests/`; Angular specs colocated with components | ✅ |
|
||||
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
|
||||
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
|
||||
| §7.2 Env configuration | `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`, `OWNER_USERNAME`, `OWNER_PASSWORD` added as env vars | ✅ |
|
||||
| §8 Scope boundaries | §8 lists "Username/password auth (planned Phase 2)" as deferred. This feature IS Phase 2; the deferral is now lifted. All other §8 items remain deferred. | ✅ |
|
||||
|
||||
**Post-design re-check**: All gates still pass after Phase 1 design.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/004-jwt-bearer-auth/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0 decisions
|
||||
├── data-model.md # Module and interface changes
|
||||
├── contracts/
|
||||
│ └── api.md # New endpoint + changed access control
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
||||
```
|
||||
|
||||
### Files changed or created
|
||||
|
||||
```text
|
||||
api/
|
||||
├── pyproject.toml # Add PyJWT dependency
|
||||
├── app/
|
||||
│ ├── config.py # Add 4 new settings
|
||||
│ ├── dependencies.py # Add require_auth dependency
|
||||
│ ├── auth/
|
||||
│ │ ├── provider.py # get_identity(authorization) signature
|
||||
│ │ ├── noop.py # Updated signature, same behaviour
|
||||
│ │ └── jwt_provider.py # NEW — JWTAuthProvider
|
||||
│ └── routers/
|
||||
│ ├── auth.py # NEW — POST /auth/token
|
||||
│ └── images.py # Protect upload, delete, patch-tags
|
||||
├── main.py # Register auth router
|
||||
└── .env.example # Add 4 new vars
|
||||
|
||||
ui/
|
||||
└── src/
|
||||
└── app/
|
||||
├── auth/
|
||||
│ ├── auth.service.ts # NEW
|
||||
│ ├── auth.service.spec.ts # NEW
|
||||
│ ├── auth.interceptor.ts # NEW
|
||||
│ ├── auth.interceptor.spec.ts # NEW
|
||||
│ └── auth.guard.ts # NEW
|
||||
├── login/
|
||||
│ ├── login.component.ts # NEW
|
||||
│ ├── login.component.html # NEW
|
||||
│ └── login.component.spec.ts # NEW
|
||||
├── detail/
|
||||
│ └── detail.component.ts # Conditionally show edit/delete
|
||||
├── app.routes.ts # Add /login; guard /upload
|
||||
├── app.config.ts # Register authInterceptor
|
||||
└── app.component.ts # Add logout button (visible when authenticated)
|
||||
```
|
||||
|
||||
## Milestones
|
||||
|
||||
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
|
||||
> the failing test(s) first, confirm they fail, then implement until they pass.
|
||||
|
||||
---
|
||||
|
||||
### M1 — JWT provider: token signing and validation
|
||||
|
||||
**Goal**: A tested `JWTAuthProvider` that can mint tokens and validate bearer
|
||||
tokens from an `Authorization` header. The `AuthProvider` interface is updated;
|
||||
`NoOpAuthProvider` is kept compatible.
|
||||
|
||||
**Deliverables**:
|
||||
- Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`
|
||||
- Update `api/app/config.py`:
|
||||
- `jwt_secret_key: str` (required — no default; validated by pydantic)
|
||||
- `jwt_expiry_seconds: int = 86400`
|
||||
- `owner_username: str` (required)
|
||||
- `owner_password: str` (required)
|
||||
- Update `api/app/auth/provider.py`: `get_identity(self, authorization: str | None) -> Identity`
|
||||
- Update `api/app/auth/noop.py`: match new signature; behaviour unchanged
|
||||
- Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider`:
|
||||
- `create_token() -> str` — mint HS256 JWT with `sub="owner"`, `iat`, `exp`
|
||||
- `verify_credentials(username, password) -> bool` — `secrets.compare_digest`
|
||||
- `get_identity(authorization) -> Identity` — parse `"Bearer <token>"`,
|
||||
decode JWT, return `Identity(id="owner", anonymous=False)` on success, or
|
||||
raise `HTTPException(401, {"detail": "...", "code": "unauthorized"})` on
|
||||
any failure (missing header, invalid format, bad signature, expired)
|
||||
|
||||
**Unit tests** in `api/tests/unit/test_jwt_auth.py` (write first, confirm fail):
|
||||
- `test_create_token_is_valid_jwt` — minted token decodes with PyJWT without error
|
||||
- `test_get_identity_returns_owner` — valid token → non-anonymous Identity
|
||||
- `test_get_identity_raises_on_expired_token` — token with past `exp` → 401
|
||||
- `test_get_identity_raises_on_wrong_key` — token signed with different key → 401
|
||||
- `test_get_identity_raises_on_garbage` — random string as token → 401
|
||||
- `test_get_identity_raises_on_missing_header` — `authorization=None` → 401
|
||||
- `test_get_identity_raises_on_missing_bearer_prefix` — `"token-without-prefix"` → 401
|
||||
- `test_verify_credentials_true` — correct username + password → True
|
||||
- `test_verify_credentials_false_wrong_password` — wrong password → False
|
||||
- `test_verify_credentials_false_wrong_username` — wrong username → False
|
||||
|
||||
**Done criterion**: All 10 unit tests pass; `ruff check api/` passes; existing
|
||||
tests unaffected.
|
||||
|
||||
---
|
||||
|
||||
### M2 — Login endpoint
|
||||
|
||||
**Goal**: `POST /api/v1/auth/token` issues a token for valid credentials and
|
||||
rejects invalid ones.
|
||||
|
||||
**Deliverables**:
|
||||
- Create `api/app/routers/auth.py`:
|
||||
- `LoginRequest` Pydantic model: `username: str`, `password: str`
|
||||
- `POST /auth/token` route: call `auth_provider.verify_credentials()`; on
|
||||
success call `auth_provider.create_token()` and return `TokenResponse`
|
||||
`{access_token, token_type="bearer", expires_in}`; on failure raise
|
||||
`401 invalid_credentials`
|
||||
- Update `api/app/dependencies.py`: instantiate `JWTAuthProvider` (reading
|
||||
settings) instead of `NoOpAuthProvider` in `get_auth()`
|
||||
- Update `api/app/main.py`: register `auth.router` under `/api/v1`
|
||||
|
||||
**Integration tests** in `api/tests/integration/test_auth.py` (write first):
|
||||
- `test_login_success` — POST valid creds → 200, response contains
|
||||
`access_token` (non-empty string), `token_type="bearer"`, `expires_in > 0`
|
||||
- `test_login_wrong_password` — correct username, wrong password → 401,
|
||||
code `invalid_credentials`
|
||||
- `test_login_wrong_username` — wrong username → 401, code `invalid_credentials`
|
||||
- `test_login_missing_password` — body `{"username": "x"}` → 422
|
||||
- `test_login_missing_username` — body `{"password": "x"}` → 422
|
||||
|
||||
**Test infrastructure note**: The integration test `conftest.py` currently
|
||||
overrides `get_auth` with `NoOpAuthProvider`. Tests for the auth endpoint
|
||||
need to override with a test `JWTAuthProvider` (a real provider with test
|
||||
credentials). Add a `jwt_auth_provider` fixture and an `authed_client` fixture
|
||||
(with a bearer token) to `conftest.py` for use in M3.
|
||||
|
||||
**Done criterion**: All 5 new tests pass; all existing tests pass (the
|
||||
`NoOpAuthProvider` override in `conftest.py` means existing tests are
|
||||
unaffected by switching the production `get_auth()` to `JWTAuthProvider`).
|
||||
|
||||
---
|
||||
|
||||
### M3 — Protected endpoints
|
||||
|
||||
**Goal**: Upload, delete, and patch-tags reject unauthenticated requests.
|
||||
All public endpoints remain accessible without a token.
|
||||
|
||||
**Deliverables**:
|
||||
- Add `require_auth` to `api/app/dependencies.py`:
|
||||
```python
|
||||
async def require_auth(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
auth: AuthProvider = Depends(get_auth),
|
||||
) -> Identity:
|
||||
identity = await auth.get_identity(authorization)
|
||||
if identity.anonymous:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"detail": "Authentication required", "code": "unauthorized"},
|
||||
)
|
||||
return identity
|
||||
```
|
||||
- In `api/app/routers/images.py`:
|
||||
- `upload_image()`: add `_: Identity = Depends(require_auth)` parameter
|
||||
- `delete_image()`: add `_: Identity = Depends(require_auth)` parameter
|
||||
- `update_image_tags()`: add `_: Identity = Depends(require_auth)` parameter
|
||||
- Remove the now-redundant `auth: AuthProvider = Depends(get_auth)` from
|
||||
`upload_image()` (it was injected but never called; `require_auth` subsumes it)
|
||||
|
||||
**Integration tests** — add to existing test files (write failing tests first):
|
||||
|
||||
In `api/tests/integration/test_upload.py`:
|
||||
- `test_upload_without_token_returns_401` — POST without `Authorization` → 401, code `unauthorized`
|
||||
- `test_upload_with_valid_token_succeeds` — POST with valid bearer token → 200/201
|
||||
|
||||
In `api/tests/integration/test_delete.py`:
|
||||
- `test_delete_without_token_returns_401` — DELETE without token → 401
|
||||
- `test_delete_with_valid_token_succeeds` — DELETE with valid token → 204
|
||||
|
||||
In `api/tests/integration/test_serving.py` (tag update lives here conceptually
|
||||
but the route is in images; add to `test_upload.py` or a new `test_tags.py`):
|
||||
- `test_patch_tags_without_token_returns_401` — PATCH without token → 401
|
||||
- `test_patch_tags_with_valid_token_succeeds` — PATCH with valid token → 200
|
||||
|
||||
Public endpoint regression tests (confirm no 401 regression):
|
||||
- `test_list_images_without_token_is_200` — GET /images → 200 (no auth)
|
||||
- `test_get_image_without_token_is_200` — GET /images/{id} → 200
|
||||
- `test_serve_file_without_token_is_200` — GET /images/{id}/file → 200
|
||||
- `test_serve_thumbnail_without_token_is_200` — GET /images/{id}/thumbnail → 200
|
||||
- `test_list_tags_without_token_is_200` — GET /tags → 200
|
||||
|
||||
**conftest.py update**: The `client` fixture already overrides `get_auth` with
|
||||
`NoOpAuthProvider`, so all existing tests (which do not send tokens) continue
|
||||
to pass without modification. The new `authed_client` fixture (from M2) uses
|
||||
a `JWTAuthProvider` override and injects a valid token via the `Authorization`
|
||||
header.
|
||||
|
||||
**Done criterion**: All new tests pass; all existing tests continue to pass.
|
||||
|
||||
---
|
||||
|
||||
### M4 — UI: `AuthService`, `AuthInterceptor`, `AuthGuard`, `LoginComponent`
|
||||
|
||||
**Goal**: Angular has a working login flow. The upload page is protected.
|
||||
The detail page shows/hides write controls based on auth state.
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
`ui/src/app/auth/auth.service.ts`:
|
||||
- `TOKEN_KEY = 'auth_token'`
|
||||
- `login(username, password): Observable<void>` — POST to `/api/v1/auth/token`,
|
||||
store `access_token` in `sessionStorage` on success
|
||||
- `logout(): void` — `sessionStorage.removeItem(TOKEN_KEY)`
|
||||
- `getToken(): string | null`
|
||||
- `isAuthenticated(): boolean`
|
||||
|
||||
`ui/src/app/auth/auth.interceptor.ts` (functional interceptor):
|
||||
- If `AuthService.getToken()` returns non-null, clone request with
|
||||
`Authorization: Bearer <token>` header; otherwise pass through
|
||||
|
||||
`ui/src/app/auth/auth.guard.ts` (`CanActivateFn`):
|
||||
- If not authenticated: `router.createUrlTree(['/login'], {queryParams: {returnUrl: state.url}})`
|
||||
- If authenticated: `true`
|
||||
|
||||
`ui/src/app/login/login.component.ts`:
|
||||
- Reactive form with `username` (required) and `password` (required) controls
|
||||
- `onSubmit()`: calls `AuthService.login()`; on success navigates to `returnUrl`
|
||||
query param (default `/`); on error displays inline "Invalid username or password"
|
||||
- Loading state while request is in flight; button disabled during loading
|
||||
|
||||
`ui/src/app/detail/detail.component.ts` (update):
|
||||
- Inject `AuthService`
|
||||
- In template: `*ngIf="auth.isAuthenticated()"` wraps the tag-edit input and
|
||||
the delete button
|
||||
|
||||
`ui/src/app/app.routes.ts` (update):
|
||||
- Add `{ path: 'login', loadComponent: () => import('./login/login.component').then(...) }`
|
||||
- Add `canActivate: [authGuard]` to the `/upload` route
|
||||
|
||||
`ui/src/app/app.config.ts` (update):
|
||||
- `provideHttpClient(withInterceptors([authInterceptor]))`
|
||||
|
||||
**Angular unit tests** (write first, confirm fail):
|
||||
|
||||
`ui/src/app/auth/auth.service.spec.ts`:
|
||||
- `test_login_stores_token` — mock HTTP, verify `sessionStorage` has token after login
|
||||
- `test_logout_clears_token` — store a token, logout, verify `sessionStorage` empty
|
||||
- `test_isAuthenticated_true_when_token_present` — set token, assert true
|
||||
- `test_isAuthenticated_false_when_no_token` — clear sessionStorage, assert false
|
||||
|
||||
`ui/src/app/auth/auth.interceptor.spec.ts`:
|
||||
- `test_adds_auth_header_when_authenticated` — authenticated state, outbound
|
||||
request has `Authorization: Bearer <token>` header
|
||||
- `test_no_auth_header_when_not_authenticated` — unauthenticated, outbound
|
||||
request has no `Authorization` header
|
||||
|
||||
`ui/src/app/login/login.component.spec.ts`:
|
||||
- `test_submit_calls_auth_service_login` — spy on `AuthService.login`, submit
|
||||
form, verify called with correct username/password
|
||||
- `test_navigates_on_success` — mock successful login, verify router navigate called
|
||||
- `test_shows_error_on_failure` — mock 401, verify error message visible
|
||||
|
||||
**Done criterion**: Angular build clean; all Angular tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M5 — `.env.example` update and final validation
|
||||
|
||||
**Goal**: New env vars documented; full test suite green.
|
||||
|
||||
**Deliverables**:
|
||||
- Update `.env.example`: add `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`,
|
||||
`OWNER_USERNAME`, `OWNER_PASSWORD` with example values and comments
|
||||
- Run `pytest api/ -v` — confirm all tests pass (expected: existing ~57 tests
|
||||
+ ~20 new tests ≈ 77 total)
|
||||
- Run `ruff check api/ && ruff format --check api/` — zero violations
|
||||
- Run `ng test` (inside UI container) — all Angular tests pass
|
||||
- Run `ng build` — Angular build succeeds
|
||||
|
||||
**Done criterion**: All tests pass; both linters pass; `docker compose up`
|
||||
starts the full stack and the login flow works end-to-end in the browser.
|
||||
|
||||
## Post-design Constitution Re-check
|
||||
|
||||
| Principle | Verdict |
|
||||
|---|---|
|
||||
| §2.4 Auth abstraction | `JWTAuthProvider` is a drop-in second implementation; business logic in routes is unchanged except for the added `require_auth` dependency | ✅ |
|
||||
| §2.6 No speculative abstraction | No new interfaces; `JWTAuthProvider` is concrete and implements an already-planned interface | ✅ |
|
||||
| §3.3 Error shape | `401` envelope uses `code` field throughout | ✅ |
|
||||
| §5.1 TDD | Failing tests precede every implementation milestone | ✅ |
|
||||
| §7.2 Env config | All four new settings come from env vars; no hardcoded credentials | ✅ |
|
||||
|
||||
All gates pass. Feature is ready for `/speckit-tasks`.
|
||||
185
specs/004-jwt-bearer-auth/research.md
Normal file
185
specs/004-jwt-bearer-auth/research.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Research: JWT Bearer Token Authentication
|
||||
|
||||
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — JWT library
|
||||
|
||||
**Decision**: `PyJWT>=2.8`
|
||||
|
||||
**Rationale**: The project needs only HS256 signing with a single symmetric
|
||||
secret key — the simplest possible JWT profile. `PyJWT` is the de-facto
|
||||
standard Python JWT library for this use case: no additional crypto
|
||||
dependencies, actively maintained, wide community adoption. `python-jose` was
|
||||
the alternative; it has broader JOSE/JWE support but has had maintenance gaps
|
||||
and brings extra dependencies that we do not need.
|
||||
|
||||
For Phase 3 (OIDC), token issuance is replaced by the external identity
|
||||
provider. The `JWTAuthProvider` will be replaced by an OIDC-aware provider;
|
||||
the library choice for Phase 2 does not constrain Phase 3.
|
||||
|
||||
**Alternatives considered**: `python-jose[cryptography]` — wider JOSE support
|
||||
but heavier dependency tree and slower maintenance cadence. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — Password storage for the single owner account
|
||||
|
||||
**Decision**: Store plaintext `OWNER_USERNAME` and `OWNER_PASSWORD` in
|
||||
environment variables; compare at login time using `secrets.compare_digest`
|
||||
to prevent timing attacks.
|
||||
|
||||
**Rationale**: This is a single-user self-hosted application accessed over a
|
||||
trusted local network. The password is already known to the person deploying
|
||||
the application (they set it). Bcrypt pre-hashing would require operators to
|
||||
run a separate tool to generate the hash before setting the env var, adding
|
||||
friction with no meaningful security benefit for this threat model. In Phase 3
|
||||
the owner credentials are replaced by an external OIDC provider entirely, so
|
||||
this is a temporary mechanism with limited lifetime.
|
||||
|
||||
`secrets.compare_digest` is used instead of `==` to prevent any theoretical
|
||||
timing oracle.
|
||||
|
||||
**Alternatives considered**: `passlib[bcrypt]` with a pre-hashed password env
|
||||
var — more "correct" in absolute terms but adds operator complexity for a
|
||||
single-user local app. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — JWT algorithm and claims
|
||||
|
||||
**Decision**: HS256 (HMAC-SHA256) with claims: `sub` (fixed string `"owner"`),
|
||||
`iat` (issued-at epoch seconds), `exp` (expiry epoch seconds).
|
||||
|
||||
**Rationale**: HS256 is symmetric — a single `JWT_SECRET_KEY` env var is used
|
||||
for both signing and verification. This is appropriate for a single-server
|
||||
deployment where only the API ever validates tokens. RS256 (asymmetric) would
|
||||
be needed if a second service needed to verify tokens independently; that is
|
||||
not the case here and would be added complexity.
|
||||
|
||||
The `sub` claim carries the owner identifier. `exp` enables configurable
|
||||
expiry. `iat` is included for auditability.
|
||||
|
||||
**Alternatives considered**: RS256 — appropriate when multiple services verify
|
||||
tokens. Overkill for this single-server deployment. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4 — Login endpoint request format
|
||||
|
||||
**Decision**: JSON body `{"username": "...", "password": "..."}` at
|
||||
`POST /api/v1/auth/token`. Response: `{"access_token": "...", "token_type": "bearer", "expires_in": <seconds>}`.
|
||||
|
||||
**Rationale**: The OAuth2 `application/x-www-form-urlencoded` format
|
||||
(`grant_type=password, username, password`) is the spec-compliant form for
|
||||
the Resource Owner Password Credentials grant. However, we are not building a
|
||||
full OAuth2 authorization server — this is a simplified login endpoint for a
|
||||
single-user SPA. A JSON body is simpler to consume from Angular's
|
||||
`HttpClient`, avoids `URLSearchParams` boilerplate, and does not mislead
|
||||
consumers into thinking this is a full OAuth2 endpoint.
|
||||
|
||||
The response shape (`access_token`, `token_type`) follows the OAuth2 bearer
|
||||
token response convention because Phase 3 (OIDC) will also produce tokens in
|
||||
this shape — the Angular `AuthService` does not need to change its
|
||||
token-parsing logic.
|
||||
|
||||
**Alternatives considered**: OAuth2 password grant form format — interoperable
|
||||
but unnecessarily strict for this use case. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5 — `AuthProvider` interface evolution
|
||||
|
||||
**Decision**: Evolve `get_identity()` to accept a single optional string
|
||||
argument: `async def get_identity(self, authorization: str | None) -> Identity`.
|
||||
`NoOpAuthProvider` ignores the argument and returns the anonymous identity as
|
||||
before. `JWTAuthProvider` parses `"Bearer <token>"`, validates the JWT, and
|
||||
returns a non-anonymous `Identity`, or raises a `401` via `HTTPException` if
|
||||
the token is invalid or expired.
|
||||
|
||||
A new `require_auth` dependency in `dependencies.py` calls
|
||||
`auth.get_identity(authorization_header)` and raises `401` if the returned
|
||||
identity is anonymous. Protected routes inject `Depends(require_auth)`.
|
||||
Public routes continue to bypass auth entirely — they neither inject auth nor
|
||||
call `get_identity`.
|
||||
|
||||
**Rationale**: Minimal interface change that preserves backward compatibility
|
||||
(`NoOpAuthProvider` continues to work unchanged) while allowing the JWT
|
||||
provider to access the request header cleanly through FastAPI's `Header`
|
||||
dependency. An alternative would be injecting `Request` directly into the
|
||||
provider, but that couples the provider to the ASGI framework; a string header
|
||||
value keeps the provider framework-agnostic.
|
||||
|
||||
**Alternatives considered**: Pass `Request` to `get_identity()` — couples the
|
||||
provider to FastAPI/ASGI. Rejected. Create a separate `validate_token(token)`
|
||||
method — more interface surface, no clear benefit over the chosen approach.
|
||||
Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 6 — Token storage in the browser
|
||||
|
||||
**Decision**: `sessionStorage` — tokens are discarded when the browser tab is
|
||||
closed.
|
||||
|
||||
**Rationale**: `localStorage` persists across browser sessions and is
|
||||
accessible to any JavaScript on the page, making it a wider XSS target.
|
||||
`sessionStorage` is scoped to the tab and cleared on close, giving better
|
||||
security for a shared or semi-public machine. For a personal app used by the
|
||||
owner on their own machine, the loss of persistence across browser restarts is
|
||||
a minor inconvenience that is well worth the security improvement.
|
||||
`HttpOnly` cookies would be more secure still but require CSRF protection and
|
||||
server-side session management, which conflicts with the stateless JWT design.
|
||||
|
||||
**Alternatives considered**: `localStorage` — persistent but wider XSS
|
||||
exposure. Rejected. `HttpOnly` cookie — strongest XSS protection but requires
|
||||
CSRF mitigation and session server state. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 7 — Angular interceptor API
|
||||
|
||||
**Decision**: Functional HTTP interceptor registered via
|
||||
`provideHttpClient(withInterceptors([authInterceptor]))` in `app.config.ts`.
|
||||
The interceptor reads the token from `AuthService.getToken()` and adds
|
||||
`Authorization: Bearer <token>` to every outbound request if a token is
|
||||
present.
|
||||
|
||||
**Rationale**: Angular 17+ prefers functional interceptors over class-based
|
||||
ones (`APP_INITIALIZER` / `HTTP_INTERCEPTORS` token). The functional pattern
|
||||
integrates with standalone components and is the current idiomatic approach.
|
||||
The interceptor attaches the token to all requests unconditionally (not just
|
||||
protected endpoints) — the API ignores the header on public endpoints, so this
|
||||
is safe and avoids the complexity of URL matching in the interceptor.
|
||||
|
||||
**Alternatives considered**: Class-based `HttpInterceptor` — legacy pattern,
|
||||
not aligned with Angular 17+ standalone idiom. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Decision 8 — No database migration required
|
||||
|
||||
**Decision**: JWTs are stateless — no token storage or blocklist is introduced.
|
||||
|
||||
**Rationale**: The tokens carry their own expiry; the API validates them by
|
||||
signature and expiry on each request. A token blocklist (for logout
|
||||
invalidation) would require a database table and lookup on every protected
|
||||
request, adding complexity disproportionate to the threat model of a
|
||||
single-user local application. On logout, the Angular client discards the
|
||||
token from `sessionStorage`; the token technically remains valid until its
|
||||
`exp`, but since there is no other client that holds it, this is acceptable.
|
||||
|
||||
**Alternatives considered**: Token blocklist table — true server-side logout.
|
||||
Out of scope for Phase 2; noted as a potential hardening step.
|
||||
|
||||
---
|
||||
|
||||
## Summary of new dependencies
|
||||
|
||||
| Package | Where | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `PyJWT>=2.8` | `api/pyproject.toml` | JWT signing and verification |
|
||||
|
||||
No new UI dependencies — Angular's `HttpClient` and `Router` cover all
|
||||
interceptor, guard, and HTTP needs.
|
||||
253
specs/004-jwt-bearer-auth/spec.md
Normal file
253
specs/004-jwt-bearer-auth/spec.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Feature Specification: JWT Bearer Token Authentication
|
||||
|
||||
**Feature Branch**: `004-jwt-bearer-auth`
|
||||
**Created**: 2026-05-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Implement authentication with JWT bearer tokens. Image uploads, image deletion, and image tag updates should be protected. Non-authenticated users should still be able see images and tags, including searching for tags."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Log In and Receive a Token (Priority: P1)
|
||||
|
||||
The owner visits the application and logs in with their username and password.
|
||||
On success, the application silently stores a credential that it will attach to
|
||||
all future requests. The owner is taken to the library without needing to take
|
||||
any further action.
|
||||
|
||||
**Why this priority**: Every protected action depends on having a valid
|
||||
credential in hand. Without a working login flow, uploads, deletions, and tag
|
||||
edits are all inaccessible.
|
||||
|
||||
**Independent Test**: Submit valid credentials via the login form. Confirm the
|
||||
application navigates to the library and that subsequent protected actions
|
||||
(upload, delete, tag edit) succeed without a second login prompt.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the owner is not logged in, **When** they open the application,
|
||||
**Then** they are presented with a login form before they can reach any
|
||||
protected action.
|
||||
|
||||
2. **Given** the owner submits their correct username and password, **When**
|
||||
the submission is processed, **Then** they are authenticated, taken to the
|
||||
library, and the credential is retained for the current session.
|
||||
|
||||
3. **Given** the owner submits an incorrect username or password, **When**
|
||||
the submission is processed, **Then** an inline error is shown ("Invalid
|
||||
credentials"), the credential is not stored, and the user remains on the
|
||||
login page.
|
||||
|
||||
4. **Given** the owner is logged in and their session has expired, **When**
|
||||
they attempt a protected action, **Then** they are redirected to the login
|
||||
page and informed their session has ended.
|
||||
|
||||
5. **Given** the owner submits the login form with an empty username or
|
||||
password field, **When** the submission is attempted, **Then** a validation
|
||||
error is shown and no authentication request is made.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Protected Write Actions Require Authentication (Priority: P1)
|
||||
|
||||
An authenticated owner can upload images, delete images, and update tags as
|
||||
before. An unauthenticated visitor who attempts these actions is turned away.
|
||||
|
||||
**Why this priority**: This is the core security requirement. Until protected
|
||||
actions reliably reject unauthenticated requests, the feature has not delivered
|
||||
its value.
|
||||
|
||||
**Independent Test**: Without logging in, attempt to upload an image via the
|
||||
API or UI. Confirm the attempt is rejected with an authentication error. Log in
|
||||
and repeat — confirm the upload succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authenticated owner, **When** they upload an image, delete an
|
||||
image, or update tags on an image, **Then** the action succeeds as it did
|
||||
before authentication was introduced.
|
||||
|
||||
2. **Given** an unauthenticated visitor, **When** they attempt to upload an
|
||||
image via the UI or API, **Then** the request is rejected with a clear
|
||||
authentication error and no image is stored.
|
||||
|
||||
3. **Given** an unauthenticated visitor, **When** they attempt to delete an
|
||||
image via the UI or API, **Then** the request is rejected and the image
|
||||
remains in the library.
|
||||
|
||||
4. **Given** an unauthenticated visitor, **When** they attempt to update tags
|
||||
on an image via the UI or API, **Then** the request is rejected and the
|
||||
tags are unchanged.
|
||||
|
||||
5. **Given** a request that carries a malformed, expired, or tampered
|
||||
credential, **When** it reaches a protected endpoint, **Then** it is
|
||||
rejected with an authentication error, not silently ignored.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Public Read Access (Priority: P1)
|
||||
|
||||
Unauthenticated visitors can browse the image library, view individual images,
|
||||
and search or filter by tags — no login required for read-only use.
|
||||
|
||||
**Why this priority**: The user explicitly requires this behaviour. Forcing
|
||||
login for read-only access would break the browse-without-an-account use case
|
||||
and is not part of the security model for this application.
|
||||
|
||||
**Independent Test**: Without a credential, call the list-images, get-image,
|
||||
serve-image, serve-thumbnail, and list-tags endpoints. All should return
|
||||
successful responses.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an unauthenticated visitor, **When** they open the library,
|
||||
**Then** all images and their tags are visible without a login prompt.
|
||||
|
||||
2. **Given** an unauthenticated visitor, **When** they apply tag filters,
|
||||
**Then** the filtered results are shown without requiring authentication.
|
||||
|
||||
3. **Given** an unauthenticated visitor, **When** they open an image detail
|
||||
page, **Then** the full-size image and its tags are displayed without a
|
||||
login prompt.
|
||||
|
||||
4. **Given** an unauthenticated visitor, **When** they browse the tag list
|
||||
or search for tags by prefix, **Then** results are returned without
|
||||
requiring authentication.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Log Out (Priority: P2)
|
||||
|
||||
The owner can end their authenticated session. After logging out, the browser
|
||||
no longer retains their credential and protected actions are blocked until
|
||||
they log in again.
|
||||
|
||||
**Why this priority**: Important for shared or public machines, but secondary
|
||||
to the core login and protection flows.
|
||||
|
||||
**Independent Test**: Log in, then log out. Attempt a protected action and
|
||||
confirm it is rejected. Refresh the page and confirm the login screen is shown.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the owner is logged in, **When** they choose to log out,
|
||||
**Then** their credential is discarded, they are returned to the login page,
|
||||
and subsequent protected actions are rejected.
|
||||
|
||||
2. **Given** the owner has logged out, **When** they navigate directly to a
|
||||
protected page (e.g., the upload form), **Then** they are redirected to the
|
||||
login page.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the owner's credential expires mid-session? → The next
|
||||
protected action fails with an authentication error; the UI redirects to
|
||||
the login page.
|
||||
- What happens when an attacker replays a valid but expired credential? → The
|
||||
request is rejected; expired credentials are never accepted.
|
||||
- What happens when the login endpoint is called many times with wrong
|
||||
credentials? → The spec does not require rate limiting or lockout in v1;
|
||||
this is noted as a future hardening concern.
|
||||
- What happens if the owner forgets their password? → Password reset is out of
|
||||
scope for v1; credentials are set via server-side configuration only.
|
||||
- What happens if the login endpoint is called while already authenticated? →
|
||||
A new credential is issued; the old one may be discarded by the client.
|
||||
- What happens when the UI receives a 401 on a read (public) endpoint? → This
|
||||
should not occur; read endpoints must never require authentication.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an endpoint that accepts a username and
|
||||
password and, on success, returns a time-limited credential the client can
|
||||
use to prove identity on subsequent requests.
|
||||
- **FR-002**: The system MUST reject login attempts that supply an incorrect
|
||||
username or password with a clear error; no credential is issued.
|
||||
- **FR-003**: The following actions MUST be protected — they MUST reject any
|
||||
request that does not carry a valid, unexpired credential:
|
||||
- Upload an image
|
||||
- Delete an image
|
||||
- Update the tags on an image
|
||||
- **FR-004**: The following actions MUST remain publicly accessible without any
|
||||
credential:
|
||||
- List images (with or without tag filters)
|
||||
- Retrieve a single image's metadata
|
||||
- Retrieve image file content
|
||||
- Retrieve image thumbnail content
|
||||
- List tags (with or without prefix filter)
|
||||
- **FR-005**: Credentials MUST have a finite lifetime; a credential issued
|
||||
before a configurable expiry window MUST be rejected.
|
||||
- **FR-006**: The system MUST reject credentials that have been tampered with
|
||||
or are otherwise invalid.
|
||||
- **FR-007**: The UI MUST automatically attach the owner's credential to every
|
||||
request that targets a protected action, without requiring the owner to
|
||||
manually supply it each time.
|
||||
- **FR-008**: The UI MUST redirect unauthenticated users to the login page when
|
||||
they attempt to reach a protected action or page.
|
||||
- **FR-009**: After a successful login, the UI MUST navigate the owner to the
|
||||
library (or to the page they originally tried to reach, if redirected from
|
||||
there).
|
||||
- **FR-010**: The owner MUST be able to log out; after logout the credential is
|
||||
discarded and protected actions are blocked until the owner logs in again.
|
||||
- **FR-011**: The owner's username and password MUST be configurable without
|
||||
changing application code (e.g., via environment variables or a configuration
|
||||
file read at startup).
|
||||
- **FR-012**: Only one set of owner credentials is required in v1; multi-user
|
||||
support is explicitly out of scope.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Credential**: A time-limited proof of identity issued to the owner after
|
||||
successful login. Key attributes: subject (owner identifier), issued-at
|
||||
timestamp, expiry timestamp, validity state (valid / expired / invalid).
|
||||
- **Login Request**: The combination of username and password submitted by the
|
||||
user to obtain a credential.
|
||||
- **Protected Endpoint**: An API endpoint that MUST reject requests that lack a
|
||||
valid credential.
|
||||
- **Public Endpoint**: An API endpoint that MUST accept requests regardless of
|
||||
whether a credential is present.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An unauthenticated visitor can browse the full image library and
|
||||
tag list without being prompted to log in.
|
||||
- **SC-002**: An unauthenticated attempt to upload, delete, or edit tags is
|
||||
rejected every time — 0% of such attempts succeed.
|
||||
- **SC-003**: An authenticated owner can complete a login-to-upload round trip
|
||||
in under 15 seconds on a local network connection.
|
||||
- **SC-004**: An expired credential is rejected on the first use after expiry;
|
||||
no grace period or retry is granted.
|
||||
- **SC-005**: After logging out, 100% of subsequent protected actions are
|
||||
rejected until the owner logs in again.
|
||||
- **SC-006**: The library, detail, tag list, and image-serving pages all load
|
||||
correctly without a credential present.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A single owner account is sufficient for v1. No user registration flow is
|
||||
required; credentials are set via environment variables or configuration at
|
||||
deployment time.
|
||||
- The application is accessed over a trusted local network connection for v1;
|
||||
HTTPS is not mandated by this spec but is assumed for any production
|
||||
deployment.
|
||||
- Credential lifetime is configurable but defaults to 24 hours. The exact
|
||||
value is a deployment decision, not a product requirement.
|
||||
- Password reset, account management, and credential revocation are out of
|
||||
scope for v1.
|
||||
- Rate limiting and account lockout after repeated failed login attempts are
|
||||
out of scope for v1; they are noted as future hardening work.
|
||||
- The UI maintains the owner's credential for the duration of the browser
|
||||
session. Behaviour after the browser is closed (persist vs. discard) follows
|
||||
a secure default for the credential storage mechanism chosen during
|
||||
implementation.
|
||||
- This is Phase 2 of a planned three-phase auth progression (no-auth →
|
||||
username/password → OIDC). The implementation MUST be structured so that
|
||||
replacing the credential issuance and validation mechanism in Phase 3 does
|
||||
not require changes to protected business logic.
|
||||
- The detail page and upload form are considered "protected pages" in the UI
|
||||
sense (require login to interact with write actions), but their read content
|
||||
(viewing image, viewing tags) remains publicly accessible at the API level.
|
||||
322
specs/004-jwt-bearer-auth/tasks.md
Normal file
322
specs/004-jwt-bearer-auth/tasks.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Tasks: JWT Bearer Token Authentication
|
||||
|
||||
**Input**: Design documents from `specs/004-jwt-bearer-auth/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
|
||||
|
||||
**TDD**: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.
|
||||
|
||||
**Organization**: Tasks follow user story priority order (US1 P1 → US2 P1 → US3 P1 → US4 P2). API milestones run first in each story, then Angular.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1–US4)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
```
|
||||
api/app/ API source
|
||||
api/tests/unit/ API unit tests
|
||||
api/tests/integration/ API integration tests
|
||||
ui/src/app/ Angular source
|
||||
ui/src/app/auth/ New auth module
|
||||
ui/src/app/login/ New login component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: New dependency, updated config, updated interfaces, and test fixtures
|
||||
that all user stories depend on. No user story work can begin until this is complete.
|
||||
|
||||
**⚠️ CRITICAL**: Complete all setup tasks before starting any user story phase.
|
||||
|
||||
- [X] T001 Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`; rebuild the Docker API image (`docker compose build api`) so PyJWT is available inside the container for all subsequent test runs
|
||||
|
||||
- [X] T002 Add four new settings to `api/app/config.py` (pydantic-settings `BaseSettings`): `jwt_secret_key: str` (required — no default, startup fails if absent), `jwt_expiry_seconds: int = 86400`, `owner_username: str` (required), `owner_password: str` (required); confirm `get_settings()` still loads from env vars via the existing `SettingsConfigDict`
|
||||
|
||||
- [X] T003 [P] Update `api/app/auth/provider.py`: change `get_identity(self)` to `get_identity(self, authorization: str | None) -> Identity`; this is a breaking interface change that will cause the `NoOpAuthProvider` to fail type-checking until T004 is done
|
||||
|
||||
- [X] T004 [P] Update `api/app/auth/noop.py`: match the new `get_identity(self, authorization: str | None) -> Identity` signature; the implementation still returns `_ANONYMOUS` and ignores `authorization`; run `pytest api/` to confirm all existing tests still pass (the conftest overrides get_auth so the interface change is invisible to running tests)
|
||||
|
||||
- [X] T005 Update `api/tests/integration/conftest.py`: add a `# TODO: complete after T007` comment block where the `jwt_auth_provider` and `authed_client` fixtures will live — do not add the import of `JWTAuthProvider` yet (it does not exist until T007 and would break `pytest api/` if imported now); the existing `client` fixture (with `NoOpAuthProvider`) must remain unchanged. After T007 is done, complete this task: add `jwt_auth_provider` fixture that constructs a `JWTAuthProvider` with test credentials, and `authed_client` fixture that overrides `get_auth` with that provider and yields `(client, valid_token)` where `valid_token = auth.create_token()`
|
||||
|
||||
**Checkpoint**: PyJWT installed, four new settings wired, interface updated, `NoOpAuthProvider` adapted, conftest ready. All existing tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (JWTAuthProvider — Blocks All Stories)
|
||||
|
||||
**Purpose**: The `JWTAuthProvider` must exist before the login endpoint (US1),
|
||||
protected endpoints (US2), or public-read regression tests (US3) can be built.
|
||||
|
||||
**⚠️ CRITICAL**: All tasks in this phase must complete before any user story work begins.
|
||||
|
||||
### Tests for JWTAuthProvider (write FIRST — must FAIL before T007) ⚠️
|
||||
|
||||
- [X] T006 Write 10 unit tests in `api/tests/unit/test_jwt_auth.py` for the (not-yet-existing) `JWTAuthProvider`: `test_create_token_is_valid_jwt` (minted token decodes with PyJWT without error), `test_get_identity_returns_owner` (valid bearer token → non-anonymous `Identity` with `id="owner"`), `test_get_identity_raises_on_expired_token` (token with past `exp` → `HTTPException` 401), `test_get_identity_raises_on_wrong_key` (token signed with different secret → 401), `test_get_identity_raises_on_garbage` (random string as token value → 401), `test_get_identity_raises_on_missing_header` (`authorization=None` → 401), `test_get_identity_raises_on_missing_bearer_prefix` (`"token-without-prefix"` → 401), `test_verify_credentials_true` (matching username + password → `True`), `test_verify_credentials_false_wrong_password` (wrong password → `False`), `test_verify_credentials_false_wrong_username` (wrong username → `False`); run `pytest api/tests/unit/test_jwt_auth.py` and confirm all 10 **fail** with `ImportError` or `AttributeError` (not-yet-implemented)
|
||||
|
||||
### JWTAuthProvider implementation
|
||||
|
||||
- [X] T007 Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider(AuthProvider)`: constructor takes `secret_key: str`, `expiry_seconds: int`, `owner_username: str`, `owner_password: str`; implement `create_token() -> str` using `jwt.encode({"sub": "owner", "iat": now, "exp": now + expiry_seconds}, secret_key, algorithm="HS256")`; implement `verify_credentials(username, password) -> bool` using `secrets.compare_digest`; implement `get_identity(authorization: str | None) -> Identity` — parse `"Bearer <token>"` (raise 401 `unauthorized` if missing or wrong prefix), decode with `jwt.decode()` (raise 401 on `ExpiredSignatureError`, `InvalidTokenError`, or any exception), return `Identity(id="owner", anonymous=False)` on success; run `pytest api/tests/unit/test_jwt_auth.py` and confirm all 10 pass; then run `pytest api/` to confirm no regressions
|
||||
|
||||
**Checkpoint**: `JWTAuthProvider` is fully implemented and tested. Login endpoint, protected-endpoint guard, and conftest fixtures can now be built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Log In and Receive a Token (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: The owner can log in with username and password and receive a bearer
|
||||
token. The Angular SPA has a working login page backed by a real API endpoint.
|
||||
|
||||
**Independent Test**: POST `{"username": "owner", "password": "correct"}` to
|
||||
`/api/v1/auth/token`. Confirm a `200` response with a non-empty `access_token`.
|
||||
Then open the browser, enter credentials in the login form, and confirm navigation
|
||||
to the library. Subsequent protected requests in the browser include the token.
|
||||
|
||||
### Tests for User Story 1 API (write FIRST — must FAIL before T009) ⚠️
|
||||
|
||||
- [X] T008 [US1] Write 5 integration tests in `api/tests/integration/test_auth.py` for the (not-yet-existing) `POST /api/v1/auth/token` endpoint: `test_login_success` (POST valid creds → 200, response has `access_token` as non-empty string, `token_type="bearer"`, `expires_in > 0`), `test_login_wrong_password` (correct username, wrong password → 401, code `invalid_credentials`), `test_login_wrong_username` (wrong username → 401, code `invalid_credentials`), `test_login_missing_password` (body `{"username": "x"}` → 422), `test_login_missing_username` (body `{"password": "x"}` → 422); these tests should use a fixture with the `JWTAuthProvider` override; run `pytest api/tests/integration/test_auth.py` and confirm all 5 **fail** with `404` (route not yet registered)
|
||||
|
||||
### Implementation for User Story 1 (API)
|
||||
|
||||
- [X] T009 [US1] In `api/app/dependencies.py`, add `get_jwt_auth() -> JWTAuthProvider` — a typed dependency that returns the same `JWTAuthProvider` instance as `get_auth()` but with the concrete type, so the auth router can call `verify_credentials()` and `create_token()` without a downcast (the login endpoint is inherently tied to token issuance and is replaced wholesale in Phase 3, so it is correct for it to depend on the concrete type rather than the `AuthProvider` abstraction). Then create `api/app/routers/auth.py`: define `LoginRequest` Pydantic model (`username: str`, `password: str`), define `TokenResponse` Pydantic model (`access_token: str`, `token_type: str = "bearer"`, `expires_in: int`), add `POST /auth/token` route that injects `auth: JWTAuthProvider = Depends(get_jwt_auth)` — calls `auth.verify_credentials(username, password)`, raises `HTTPException(401, {"detail": "Invalid credentials", "code": "invalid_credentials"})` on failure, calls `auth.create_token()` and returns `TokenResponse` on success; complete T005's `conftest.py` `jwt_auth_provider` fixture import now that the module exists
|
||||
|
||||
- [X] T010 [US1] Update `api/app/dependencies.py`: in `get_auth()`, replace `NoOpAuthProvider()` with `JWTAuthProvider(secret_key=s.jwt_secret_key, expiry_seconds=s.jwt_expiry_seconds, owner_username=s.owner_username, owner_password=s.owner_password)` (loading settings via `get_settings()`); the existing `client` fixture in `conftest.py` still overrides `get_auth` with `NoOpAuthProvider`, so all existing tests remain unaffected
|
||||
|
||||
- [X] T011 [US1] Update `api/app/main.py`: import `auth` router from `app.routers.auth` and register it with `app.include_router(auth.router, prefix="/api/v1")`; run `pytest api/tests/integration/test_auth.py` and confirm all 5 tests pass; run `pytest api/` and confirm no regressions
|
||||
|
||||
### Tests for User Story 1 (Angular — write FIRST — must FAIL before T014) ⚠️
|
||||
|
||||
- [X] T012 [P] [US1] Write 3 unit tests in `ui/src/app/auth/auth.service.spec.ts` for the (not-yet-existing) `AuthService`: `test_login_stores_token` (mock `HttpClient` POST returning `{access_token: "tok"}`, verify `sessionStorage.getItem("auth_token") === "tok"` after `login()` completes), `test_isAuthenticated_true_when_token_present` (set token in sessionStorage, assert `isAuthenticated()` returns true), `test_isAuthenticated_false_when_no_token` (clear sessionStorage, assert `isAuthenticated()` returns false); run `ng test` and confirm all 3 **fail** with `Cannot find module` or similar. Note: logout tests belong to US4 and are written in T025.
|
||||
|
||||
- [X] T013 [P] [US1] Write 4 unit tests in `ui/src/app/login/login.component.spec.ts` for the (not-yet-existing) `LoginComponent`: `test_submit_calls_auth_service_login` (spy on `AuthService.login`, fill form, submit, verify `login` called with correct username and password), `test_navigates_to_library_on_success` (mock `AuthService.login` returning `of(void 0)`, submit, verify `Router.navigate` called with `['/']`), `test_shows_error_on_401` (mock `AuthService.login` throwing `HttpErrorResponse` with status 401, submit, verify error message element is visible in the template), `test_shows_validation_error_on_empty_fields` (disable browser-native validation via `novalidate`, leave username and password blank, click submit, verify no `HttpClient.post` call was made and a validation error element is visible in the DOM); run `ng test` and confirm all 4 **fail**
|
||||
|
||||
### Implementation for User Story 1 (Angular)
|
||||
|
||||
- [X] T014 [P] [US1] Create `ui/src/app/auth/auth.service.ts`: `TOKEN_KEY = 'auth_token'`; `login(username: string, password: string): Observable<void>` — POST `/api/v1/auth/token`, pipe `tap(res => sessionStorage.setItem(this.TOKEN_KEY, res.access_token))`, map to void; `logout(): void` — `sessionStorage.removeItem(this.TOKEN_KEY)`; `getToken(): string | null` — `sessionStorage.getItem(this.TOKEN_KEY)`; `isAuthenticated(): boolean` — `this.getToken() !== null`; decorate with `@Injectable({ providedIn: 'root' })`
|
||||
|
||||
- [X] T015 [P] [US1] Create `ui/src/app/login/login.component.ts` (standalone component, route `/login`): reactive form with `username` (required) and `password` (required) validators; `onSubmit()` calls `AuthService.login()`, sets `loading = true` while in-flight, on success reads `returnUrl` query param (default `'/'`) and calls `router.navigateByUrl(returnUrl)`, on error sets `errorMessage = 'Invalid username or password'`; template (`login.component.html`) includes a form with username input, password input, submit button (disabled while loading), and an error paragraph (`*ngIf="errorMessage"`)
|
||||
|
||||
- [X] T016 [US1] Update `ui/src/app/app.routes.ts`: add `{ path: 'login', loadComponent: () => import('./login/login.component').then(m => m.LoginComponent) }` before the wildcard route; run `ng test` and confirm T012 and T013 tests now pass; run `ng build` to confirm no build errors
|
||||
|
||||
**Checkpoint**: `POST /api/v1/auth/token` works end-to-end. Angular login form posts credentials, stores token, navigates to library. All 12 new tests (5 API + 3 Angular service + 4 Angular login) pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Protected Write Actions Require Authentication (Priority: P1)
|
||||
|
||||
**Goal**: Upload, delete, and tag-update endpoints reject unauthenticated requests
|
||||
with a `401`. Angular automatically attaches the stored token to all requests.
|
||||
|
||||
**Independent Test**: Without logging in, attempt `POST /api/v1/images` — confirm
|
||||
`401` with code `unauthorized`. Log in, then upload again — confirm `200/201`. In
|
||||
the browser, verify that after login the upload form submits successfully.
|
||||
|
||||
### Tests for User Story 2 API (write FIRST — must FAIL before T019) ⚠️
|
||||
|
||||
- [X] T017 [US2] Add 6 integration tests using the `authed_client` fixture across existing test files: in `api/tests/integration/test_upload.py` add `test_upload_without_token_returns_401` (POST with no `Authorization` header → 401, code `unauthorized`) and `test_upload_with_valid_token_succeeds` (POST with `Authorization: Bearer <token>` → 200/201); in `api/tests/integration/test_delete.py` add `test_delete_without_token_returns_401` (DELETE with no token → 401) and `test_delete_with_valid_token_succeeds` (DELETE with valid token → 204); add `test_patch_tags_without_token_returns_401` (PATCH `/images/{id}/tags` with no token → 401) and `test_patch_tags_with_valid_token_succeeds` (PATCH with valid token → 200) to `api/tests/integration/test_upload.py` (or a new `test_protected.py`); run these 6 tests and confirm they all **fail** (currently return 200/204 without auth, or fixture not yet usable)
|
||||
|
||||
### Implementation for User Story 2 (API)
|
||||
|
||||
- [X] T018 [US2] Add `require_auth` async dependency to `api/app/dependencies.py`: `async def require_auth(authorization: str | None = Header(None, alias="Authorization"), auth: AuthProvider = Depends(get_auth)) -> Identity` — calls `await auth.get_identity(authorization)`, raises `HTTPException(401, {"detail": "Authentication required", "code": "unauthorized"})` if `identity.anonymous` is True, otherwise returns `Identity`
|
||||
|
||||
- [X] T019 [US2] In `api/app/routers/images.py`: add `_: Identity = Depends(require_auth)` parameter to `upload_image()`, `delete_image()`, and `update_image_tags()`; also remove the existing `auth: AuthProvider = Depends(get_auth)` from `upload_image()` (it was injected but never called — `require_auth` now subsumes it); add `from app.dependencies import require_auth` and `from app.auth.provider import Identity` to imports; run `pytest api/tests/integration/` and confirm all 6 new protected-endpoint tests pass and all pre-existing tests (which use the `client` fixture with `NoOpAuthProvider` override) still pass
|
||||
|
||||
### Tests for User Story 2 (Angular — write FIRST — must FAIL before T021) ⚠️
|
||||
|
||||
- [X] T020 [US2] Write 3 unit tests in `ui/src/app/auth/auth.interceptor.spec.ts` for the (not-yet-existing) `authInterceptor`: `test_adds_auth_header_when_authenticated` (configure `TestBed` with `authInterceptor`, spy `AuthService.getToken()` returning `"test-token"`, make any HTTP request via `HttpClient`, verify the outgoing request in `HttpTestingController` has header `Authorization: Bearer test-token`), `test_no_auth_header_when_not_authenticated` (spy `AuthService.getToken()` returning `null`, make HTTP request, verify `Authorization` header is absent), `test_interceptor_redirects_to_login_on_401` (spy `AuthService.getToken()` returning `"test-token"`, flush the HTTP response with status 401, spy `AuthService.logout()` and `Router.navigate`, verify `logout()` was called and `router.navigate(['/login'])` was called); run `ng test` and confirm all 3 **fail**
|
||||
|
||||
### Implementation for User Story 2 (Angular)
|
||||
|
||||
- [X] T021 [US2] Create `ui/src/app/auth/auth.interceptor.ts` as a functional interceptor that handles both outbound token injection and inbound 401 responses: `export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); const token = auth.getToken(); if (token) { req = req.clone({ setHeaders: { Authorization: \`Bearer \${token}\` } }); } return next(req).pipe(catchError(err => { if (err instanceof HttpErrorResponse && err.status === 401) { auth.logout(); router.navigate(['/login']); } return throwError(() => err); })); }`; import `catchError`, `throwError` from `rxjs/operators` and `HttpErrorResponse` from `@angular/common/http`
|
||||
|
||||
- [X] T022 [US2] Update `ui/src/app/app.config.ts`: change `provideHttpClient()` to `provideHttpClient(withInterceptors([authInterceptor]))`; add the necessary imports; run `ng test` and confirm T020's 3 tests now pass; run `ng build` to confirm no build errors; run `pytest api/` to confirm all API tests still pass
|
||||
|
||||
**Checkpoint**: Upload, delete, and tag-update reject unauthenticated requests. The Angular interceptor attaches the token on outbound requests and redirects to `/login` on any 401 response (covering expired mid-session tokens). All 9 new tests (6 API + 3 Angular) pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Public Read Access (Priority: P1)
|
||||
|
||||
**Goal**: All read endpoints remain accessible without any credential. Verify no
|
||||
401 regression was introduced by the changes in US2.
|
||||
|
||||
**Independent Test**: Without providing any `Authorization` header, call
|
||||
`GET /api/v1/images`, `GET /api/v1/images/{id}`, `GET /api/v1/images/{id}/file`,
|
||||
`GET /api/v1/images/{id}/thumbnail`, and `GET /api/v1/tags`. All must return `200`.
|
||||
|
||||
### Tests for User Story 3 (write FIRST — must FAIL before T024) ⚠️
|
||||
|
||||
- [X] T023 [US3] Add 5 regression integration tests using the `authed_client` fixture (which uses `JWTAuthProvider` but no `Authorization` header in the request) in a new `api/tests/integration/test_public_access.py`: `test_list_images_without_token_is_200` (GET `/api/v1/images` with no auth header → 200), `test_get_image_without_token_is_200` (upload image first using `authed_client` with token, then GET `/api/v1/images/{id}` with no auth header → 200), `test_serve_file_without_token_is_200` (GET `/api/v1/images/{id}/file` with no auth header → 200), `test_serve_thumbnail_without_token_is_200` (GET `/api/v1/images/{id}/thumbnail` with no auth header → 200), `test_list_tags_without_token_is_200` (GET `/api/v1/tags` with no auth header → 200); run these tests and confirm they all **fail** (they will fail until T019 is complete because the `authed_client` fixture may not yet be fully wired — or they may pass if the fixture is ready, in which case document as already-green)
|
||||
|
||||
### Verification for User Story 3
|
||||
|
||||
- [X] T024 [US3] Run `pytest api/tests/integration/test_public_access.py` and confirm all 5 pass; run `pytest api/ -v` to confirm the full API suite passes without regressions; document the passing test count
|
||||
|
||||
**Checkpoint**: All public read endpoints confirmed accessible without a token. No 401 regression introduced by the protected-write changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Log Out (Priority: P2)
|
||||
|
||||
**Goal**: The owner can end their session. After logout, the token is gone from
|
||||
the browser and the upload page redirects to login.
|
||||
|
||||
**Independent Test**: Log in, verify the upload page is accessible. Click the
|
||||
logout control. Verify the application navigates to `/login`. Navigate directly
|
||||
to `/upload` — confirm redirect to `/login`.
|
||||
|
||||
### Tests for User Story 4 (write FIRST — must FAIL before T027) ⚠️
|
||||
|
||||
- [X] T025 [P] [US4] Add 2 unit tests to `ui/src/app/auth/auth.service.spec.ts`: `test_logout_removes_token_from_storage` (set a token in sessionStorage, call `logout()`, confirm `sessionStorage.getItem("auth_token")` is null), `test_isAuthenticated_false_after_logout` (set token, call `logout()`, confirm `isAuthenticated()` returns false); these tests cover logout behaviour which belongs to US4 and was intentionally excluded from T012; `logout()` is implemented in T014 so these tests should pass immediately — confirm they pass before proceeding
|
||||
|
||||
- [X] T026 [P] [US4] Write 1 unit test in a new `ui/src/app/auth/auth.guard.spec.ts` for the (not-yet-existing) `authGuard`: `test_redirects_to_login_when_not_authenticated` — configure `TestBed` with `provideRouter([])` and `provideLocationMocks()` (standalone Angular 17+ pattern; do NOT use the deprecated `RouterTestingModule`), spy `AuthService.isAuthenticated()` returning `false`, execute the guard function directly with a mock `ActivatedRouteSnapshot` and `RouterStateSnapshot` with `url = '/upload'`, assert the returned value is a `UrlTree` whose `toString()` starts with `/login`; run `ng test` and confirm it **fails**
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T027 [P] [US4] Create `ui/src/app/auth/auth.guard.ts` as a functional `CanActivateFn`: inject `AuthService` and `Router`; if `auth.isAuthenticated()` return `true`; else return `router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })`
|
||||
|
||||
- [X] T028 [P] [US4] Update `ui/src/app/app.routes.ts`: add `canActivate: [authGuard]` to the `/upload` route entry; add import for `authGuard`; add `CanActivateFn` guard to the route object
|
||||
|
||||
- [X] T029 [P] [US4] Update `ui/src/app/detail/detail.component.ts`: inject `AuthService` as `public auth: AuthService`; in the template, wrap the tag-edit input block and the delete button with `*ngIf="auth.isAuthenticated()"` so they are hidden for unauthenticated visitors; the image display and read-only tag chips remain visible to all
|
||||
|
||||
- [X] T030 [US4] Add a logout link/button to the application shell (`ui/src/app/app.component.ts` and its template): inject `AuthService` and `Router`; add `onLogout()` method that calls `auth.logout()` then `router.navigate(['/login'])`; render the button only when `auth.isAuthenticated()` is true; run `ng test` and confirm T025 and T026 tests pass; run `ng build` to confirm no build errors
|
||||
|
||||
**Checkpoint**: Logout works. Upload page is guarded. Detail page hides write controls for unauthenticated visitors. All 3 new Angular tests (2 service + 1 guard) pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Environment documentation, final linting, and complete test run.
|
||||
|
||||
- [X] T031 [P] Update `.env.example`: add four new variables with comments:
|
||||
```
|
||||
# Owner credentials and JWT signing secret
|
||||
JWT_SECRET_KEY=change-me-to-a-long-random-string
|
||||
JWT_EXPIRY_SECONDS=86400
|
||||
OWNER_USERNAME=owner
|
||||
OWNER_PASSWORD=change-me
|
||||
```
|
||||
|
||||
- [X] T032 [P] Run `~/.local/bin/ruff check api/app/auth/jwt_provider.py api/app/routers/auth.py api/app/dependencies.py api/app/config.py api/app/routers/images.py` and `ruff format --check` on the same files; fix any lint or formatting violations
|
||||
|
||||
- [X] T033 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: ~57 existing + ~18 new ≈ 75 total)
|
||||
|
||||
- [X] T034 Run `ng test` inside the UI container (or locally) and confirm all Angular unit tests pass; run `ng build` and confirm the Angular build succeeds with no errors
|
||||
|
||||
- [X] T035 End-to-end smoke test: `docker compose up`, open the browser, verify: (a) the library loads without login, (b) navigating to `/upload` redirects to `/login`, (c) logging in navigates to the library, (d) uploading an image succeeds, (e) logging out redirects to `/login`, (f) attempting `/upload` again redirects to `/login`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 complete (PyJWT must be installed before tests can import `JWTAuthProvider`)
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 complete (`JWTAuthProvider` must exist before login endpoint tests reference it)
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 complete (Angular interceptor needs `AuthService`; API `require_auth` needs `JWTAuthProvider`)
|
||||
- **US3 (Phase 5)**: Depends on Phase 4 complete (`require_auth` must be wired before public-access regression tests are meaningful)
|
||||
- **US4 (Phase 6)**: Depends on Phase 3 complete (`AuthService.logout()` may already be implemented in T014; guard and route changes depend on login route existing from T016)
|
||||
- **Polish (Phase 7)**: Depends on all feature phases complete
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- T006 (write failing tests) MUST precede T007 (implement JWTAuthProvider)
|
||||
- T008 (write failing API auth tests) MUST precede T009 (implement login route)
|
||||
- T012 and T013 (write failing Angular tests) MUST precede T014 and T015
|
||||
- T017 (write failing 401 tests) MUST precede T018 + T019
|
||||
- T020 (write failing interceptor tests) MUST precede T021
|
||||
- T023 (write failing public-access tests) MUST precede T024
|
||||
- T025 and T026 (write failing US4 tests) MUST precede T027–T030
|
||||
|
||||
### Parallel Opportunities (within phases)
|
||||
|
||||
- T003 and T004 can run in parallel (different files in `api/app/auth/`)
|
||||
- T009, T010, T011 are sequential (dependencies.py → auth.py → main.py)
|
||||
- T012 and T013 can run in parallel (different spec files)
|
||||
- T014 and T015 can run in parallel after T012 and T013 (different component files)
|
||||
- T020 MUST precede T021 (TDD: confirm 3 tests fail before implementing interceptor)
|
||||
- T025 and T026 can run in parallel (different spec files)
|
||||
- T027, T028, T029 can run in parallel (different files: guard, routes, detail component)
|
||||
- T031 and T032 can run in parallel
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 1 (Setup)
|
||||
|
||||
```bash
|
||||
# T003 and T004 touch different files — run together:
|
||||
Task: "Update AuthProvider interface signature in api/app/auth/provider.py"
|
||||
Task: "Update NoOpAuthProvider signature in api/app/auth/noop.py"
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 3 / US1 (Angular)
|
||||
|
||||
```bash
|
||||
# T012 and T013 touch different spec files — run together:
|
||||
Task: "Write 3 failing AuthService unit tests in ui/src/app/auth/auth.service.spec.ts"
|
||||
Task: "Write 4 failing LoginComponent unit tests in ui/src/app/login/login.component.spec.ts"
|
||||
|
||||
# T014 and T015 touch different source files — run together after T012/T013:
|
||||
Task: "Create AuthService in ui/src/app/auth/auth.service.ts"
|
||||
Task: "Create LoginComponent in ui/src/app/login/login.component.ts"
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 4 / US2 (Angular)
|
||||
|
||||
```bash
|
||||
# T020 MUST precede T021 (TDD). Within US2 they are sequential.
|
||||
# T020 can run in parallel with other US2 API tasks (T017, T018, T019 touch different files):
|
||||
Task: "Write 3 failing interceptor tests in ui/src/app/auth/auth.interceptor.spec.ts" # T020
|
||||
# (after T020 confirms failing):
|
||||
Task: "Create authInterceptor in ui/src/app/auth/auth.interceptor.ts" # T021
|
||||
```
|
||||
|
||||
## Parallel Example: Phase 6 / US4
|
||||
|
||||
```bash
|
||||
# T027, T028, T029 touch different files — run together:
|
||||
Task: "Create authGuard in ui/src/app/auth/auth.guard.ts"
|
||||
Task: "Add canActivate guard to /upload route in ui/src/app/app.routes.ts"
|
||||
Task: "Conditionally show write controls in ui/src/app/detail/detail.component.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (User Stories 1 + 2 + 3 — minimum shippable auth)
|
||||
|
||||
All three P1 stories are interdependent: login (US1) enables write-protection
|
||||
(US2), and write-protection must not break public reads (US3). Complete phases
|
||||
in order:
|
||||
|
||||
1. Phase 1: Setup (T001–T005)
|
||||
2. Phase 2: Foundational JWT provider (T006–T007)
|
||||
3. Phase 3: US1 Login API + Angular (T008–T016)
|
||||
4. Phase 4: US2 Protected writes API + Angular interceptor (T017–T022)
|
||||
5. Phase 5: US3 Public-read regression (T023–T024)
|
||||
6. **STOP and VALIDATE**: Login, upload (authenticated), and public browse all work
|
||||
|
||||
### Incremental add-on: Logout (US4)
|
||||
|
||||
Once MVP is validated, add Phase 6 (T025–T030) to complete the session
|
||||
lifecycle. This is independently addable without revisiting previous phases.
|
||||
|
||||
### Total tasks: 35 (T001–T035)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and have no mutual dependencies within their phase
|
||||
- T006, T008, T012, T013, T017, T020, T023, T025, T026 are all "write failing test" steps — always confirm failure before implementing
|
||||
- The `client` fixture in `conftest.py` uses `NoOpAuthProvider` and MUST NOT be changed — all existing tests depend on it passing without a token
|
||||
- The `authed_client` fixture returns `(client, valid_token)` — tests choose whether to include the token, enabling both 401 and success scenarios from the same fixture
|
||||
- The `authInterceptor` attaches the token unconditionally to all requests; the API silently ignores the `Authorization` header on public endpoints — no URL matching needed in the interceptor
|
||||
- Logout in the UI invalidates the client-side session only; the JWT technically remains valid until its `exp` (acceptable for a single-user local app with no token revocation)
|
||||
16734
ui/package-lock.json
generated
Normal file
16734
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,31 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet />`,
|
||||
imports: [CommonModule, RouterOutlet],
|
||||
template: `
|
||||
<header class="app-header" *ngIf="auth.isAuthenticated()">
|
||||
<button class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||
</header>
|
||||
<router-outlet />
|
||||
`,
|
||||
styles: [`
|
||||
.app-header { display: flex; justify-content: flex-end; padding: 8px 16px; background: #1a1a1a; border-bottom: 1px solid #333; }
|
||||
.logout-btn { background: none; border: 1px solid #555; color: #aaa; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
|
||||
.logout-btn:hover { border-color: #aaa; color: #e0e0e0; }
|
||||
`],
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'reactbin-ui';
|
||||
|
||||
constructor(public auth: AuthService, private router: Router) {}
|
||||
|
||||
onLogout(): void {
|
||||
this.auth.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './auth/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './auth/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -6,8 +7,14 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./library/library.component').then((m) => m.LibraryComponent),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./upload/upload.component').then((m) => m.UploadComponent),
|
||||
},
|
||||
|
||||
34
ui/src/app/auth/auth.guard.spec.ts
Normal file
34
ui/src/app/auth/auth.guard.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
|
||||
import { provideLocationMocks } from '@angular/common/testing';
|
||||
import { authGuard } from './auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('authGuard', () => {
|
||||
let authService: jasmine.SpyObj<AuthService>;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(() => {
|
||||
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideLocationMocks(),
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
});
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
it('redirects to login when not authenticated', () => {
|
||||
authService.isAuthenticated.and.returnValue(false);
|
||||
const route = {} as ActivatedRouteSnapshot;
|
||||
const state = { url: '/upload' } as RouterStateSnapshot;
|
||||
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
expect(result).toBeTruthy();
|
||||
const urlTree = result as ReturnType<Router['createUrlTree']>;
|
||||
expect(urlTree.toString()).toContain('/login');
|
||||
});
|
||||
});
|
||||
12
ui/src/app/auth/auth.guard.ts
Normal file
12
ui/src/app/auth/auth.guard.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = (_route, state) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
|
||||
};
|
||||
57
ui/src/app/auth/auth.interceptor.spec.ts
Normal file
57
ui/src/app/auth/auth.interceptor.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let authService: jasmine.SpyObj<AuthService>;
|
||||
let router: jasmine.SpyObj<Router>;
|
||||
|
||||
beforeEach(() => {
|
||||
authService = jasmine.createSpyObj('AuthService', ['getToken', 'logout']);
|
||||
router = jasmine.createSpyObj('Router', ['navigate']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('adds Authorization header when authenticated', () => {
|
||||
authService.getToken.and.returnValue('test-token');
|
||||
http.get('/api/v1/images').subscribe();
|
||||
const req = httpMock.expectOne('/api/v1/images');
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('does not add Authorization header when not authenticated', () => {
|
||||
authService.getToken.and.returnValue(null);
|
||||
http.get('/api/v1/images').subscribe();
|
||||
const req = httpMock.expectOne('/api/v1/images');
|
||||
expect(req.request.headers.has('Authorization')).toBeFalse();
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('redirects to login on 401 response', () => {
|
||||
authService.getToken.and.returnValue('test-token');
|
||||
http.get('/api/v1/images').subscribe({ error: () => {} });
|
||||
const req = httpMock.expectOne('/api/v1/images');
|
||||
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
||||
expect(authService.logout).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
});
|
||||
23
ui/src/app/auth/auth.interceptor.ts
Normal file
23
ui/src/app/auth/auth.interceptor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const token = auth.getToken();
|
||||
if (token) {
|
||||
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
return next(req).pipe(
|
||||
catchError((err) => {
|
||||
if (err instanceof HttpErrorResponse && err.status === 401) {
|
||||
auth.logout();
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
};
|
||||
55
ui/src/app/auth/auth.service.spec.ts
Normal file
55
ui/src/app/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [AuthService],
|
||||
});
|
||||
service = TestBed.inject(AuthService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('login stores token in sessionStorage', (done) => {
|
||||
service.login('owner', 'password').subscribe(() => {
|
||||
expect(sessionStorage.getItem('auth_token')).toBe('test-token');
|
||||
done();
|
||||
});
|
||||
const req = httpMock.expectOne('/api/v1/auth/token');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush({ access_token: 'test-token', token_type: 'bearer', expires_in: 3600 });
|
||||
});
|
||||
|
||||
it('isAuthenticated returns true when token is present', () => {
|
||||
sessionStorage.setItem('auth_token', 'some-token');
|
||||
expect(service.isAuthenticated()).toBeTrue();
|
||||
});
|
||||
|
||||
it('isAuthenticated returns false when no token', () => {
|
||||
expect(service.isAuthenticated()).toBeFalse();
|
||||
});
|
||||
|
||||
// US4 logout tests (T025)
|
||||
it('logout removes token from sessionStorage', () => {
|
||||
sessionStorage.setItem('auth_token', 'tok');
|
||||
service.logout();
|
||||
expect(sessionStorage.getItem('auth_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('isAuthenticated returns false after logout', () => {
|
||||
sessionStorage.setItem('auth_token', 'tok');
|
||||
service.logout();
|
||||
expect(service.isAuthenticated()).toBeFalse();
|
||||
});
|
||||
});
|
||||
37
ui/src/app/auth/auth.service.ts
Normal file
37
ui/src/app/auth/auth.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map, tap } from 'rxjs';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
login(username: string, password: string): Observable<void> {
|
||||
return this.http
|
||||
.post<TokenResponse>('/api/v1/auth/token', { username, password })
|
||||
.pipe(
|
||||
tap((res) => sessionStorage.setItem(TOKEN_KEY, res.access_token)),
|
||||
map(() => undefined),
|
||||
);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return sessionStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.getToken() !== null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const MOCK_IMAGE = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
storage_key: 'abc',
|
||||
thumbnail_key: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
tags: ['cat', 'funny'],
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-detail',
|
||||
@@ -20,10 +21,10 @@ import { ImageRecord, ImageService } from '../services/image.service';
|
||||
<h3>Tags</h3>
|
||||
<div class="chips">
|
||||
<span *ngFor="let tag of image.tags" class="chip">
|
||||
{{ tag }} <button (click)="removeTag(tag)">×</button>
|
||||
{{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-tag">
|
||||
<div class="add-tag" *ngIf="auth.isAuthenticated()">
|
||||
<input
|
||||
[(ngModel)]="newTagInput"
|
||||
placeholder="Add tag…"
|
||||
@@ -34,7 +35,7 @@ import { ImageRecord, ImageService } from '../services/image.service';
|
||||
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||||
</section>
|
||||
|
||||
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||
<button *ngIf="auth.isAuthenticated()" class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||
|
||||
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
||||
<div class="dialog">
|
||||
@@ -75,6 +76,7 @@ export class DetailComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
public imageService: ImageService,
|
||||
public auth: AuthService,
|
||||
private route: ActivatedRoute,
|
||||
public router: Router,
|
||||
private cdr: ChangeDetectorRef,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('LibraryComponent', () => {
|
||||
spyOn(imgSvc, 'list').and.returnValue(
|
||||
of({
|
||||
items: [
|
||||
{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', created_at: '' },
|
||||
{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' },
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
|
||||
@@ -48,7 +48,7 @@ import { TagService } from '../services/tag.service';
|
||||
class="image-card"
|
||||
(click)="router.navigate(['/images', img.id])"
|
||||
>
|
||||
<img [src]="imageService.getFileUrl(img.id)" [alt]="img.filename" loading="lazy" />
|
||||
<img [src]="imageService.getThumbnailUrl(img.id)" [alt]="img.filename" loading="lazy" />
|
||||
<div class="tag-row">
|
||||
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
63
ui/src/app/login/login.component.spec.ts
Normal file
63
ui/src/app/login/login.component.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { LoginComponent } from './login.component';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
describe('LoginComponent', () => {
|
||||
let component: LoginComponent;
|
||||
let authService: jasmine.SpyObj<AuthService>;
|
||||
let router: jasmine.SpyObj<Router>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authService = jasmine.createSpyObj('AuthService', ['login']);
|
||||
router = jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginComponent, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LoginComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('submit calls AuthService.login with username and password', fakeAsync(() => {
|
||||
authService.login.and.returnValue(of(undefined));
|
||||
component.form.setValue({ username: 'owner', password: 'hunter2' });
|
||||
component.onSubmit();
|
||||
tick();
|
||||
expect(authService.login).toHaveBeenCalledWith('owner', 'hunter2');
|
||||
}));
|
||||
|
||||
it('navigates to library on success', fakeAsync(() => {
|
||||
authService.login.and.returnValue(of(undefined));
|
||||
router.navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||
component.form.setValue({ username: 'owner', password: 'hunter2' });
|
||||
component.onSubmit();
|
||||
tick();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
}));
|
||||
|
||||
it('shows error message on 401', fakeAsync(() => {
|
||||
const err = new HttpErrorResponse({ status: 401 });
|
||||
authService.login.and.returnValue(throwError(() => err));
|
||||
component.form.setValue({ username: 'owner', password: 'wrong' });
|
||||
component.onSubmit();
|
||||
tick();
|
||||
expect(component.errorMessage).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('does not call login when fields are empty', fakeAsync(() => {
|
||||
component.form.setValue({ username: '', password: '' });
|
||||
component.onSubmit();
|
||||
tick();
|
||||
expect(authService.login).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
73
ui/src/app/login/login.component.ts
Normal file
73
ui/src/app/login/login.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="login-page">
|
||||
<h1>Sign In</h1>
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" formControlName="username" />
|
||||
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
|
||||
Username is required
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" formControlName="password" />
|
||||
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
|
||||
Password is required
|
||||
</span>
|
||||
</div>
|
||||
<p *ngIf="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||
<button type="submit" [disabled]="loading">
|
||||
{{ loading ? 'Signing in…' : 'Sign In' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
form: FormGroup;
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private auth: AuthService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
username: ['', Validators.required],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
const { username, password } = this.form.value;
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/';
|
||||
this.auth.login(username, password).subscribe({
|
||||
next: () => {
|
||||
this.loading = false;
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Invalid username or password';
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface ImageRecord {
|
||||
width: number;
|
||||
height: number;
|
||||
storage_key: string;
|
||||
thumbnail_key: string | null;
|
||||
created_at: string;
|
||||
tags: string[];
|
||||
duplicate?: boolean;
|
||||
@@ -54,6 +55,10 @@ export class ImageService {
|
||||
return `${this.base}/images/${id}/file`;
|
||||
}
|
||||
|
||||
getThumbnailUrl(id: string): string {
|
||||
return `${this.base}/images/${id}/thumbnail`;
|
||||
}
|
||||
|
||||
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
|
||||
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ describe('UploadComponent', () => {
|
||||
let component: UploadComponent;
|
||||
|
||||
function makeImageService(overrides: Partial<ImageService> = {}): jasmine.SpyObj<ImageService> {
|
||||
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides } as any);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
Reference in New Issue
Block a user