diff --git a/README.md b/README.md index 799a76ec..30e3d1bc 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ Perfect for music researchers, data scientists, developers, and music enthusiast | Service | Purpose | Key Technologies | | ------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------- | | **[🔐](docs/emoji-guide.md#service-identifiers) API** | User accounts and JWT authentication | `FastAPI`, `psycopg3`, `redis`, Discogs OAuth 1.0 | -| **[🗂️](docs/emoji-guide.md#service-identifiers) Curator** | Discogs collection & wantlist sync | `FastAPI`, `psycopg3`, `neo4j-driver` | +| **[🗂️](docs/emoji-guide.md#service-identifiers) Curator** | Background collection & wantlist sync | `FastAPI`, `psycopg3`, `neo4j-driver` | | **[📊](docs/emoji-guide.md#service-identifiers) Dashboard** | Real-time system monitoring | `FastAPI`, WebSocket, reactive UI | -| **[🔍](docs/emoji-guide.md#service-identifiers) Explore** | Interactive graph exploration & trends | `FastAPI`, `D3.js`, `Plotly.js`, Neo4j | +| **[🔍](docs/emoji-guide.md#service-identifiers) Explore** | Serves graph exploration frontend (static files) | `FastAPI`, `D3.js`, `Plotly.js` | | **[⚡](docs/emoji-guide.md#service-identifiers) Extractor** | High-performance Rust-based extractor | `tokio`, `quick-xml`, `lapin` | | **[🔗](docs/emoji-guide.md#service-identifiers) Graphinator** | Builds Neo4j knowledge graphs | `neo4j-driver`, graph algorithms | | **[🔧](docs/emoji-guide.md#service-identifiers) Schema-Init** | One-shot database schema initializer | `neo4j-driver`, `psycopg3` | diff --git a/api/README.md b/api/README.md index ff3d724d..eccfae0c 100644 --- a/api/README.md +++ b/api/README.md @@ -33,7 +33,7 @@ POSTGRES_USERNAME=discogsography POSTGRES_PASSWORD=discogsography POSTGRES_DATABASE=discogsography -# Redis (OAuth state storage) +# Redis (OAuth state + JTI blacklist storage) REDIS_URL=redis://redis:6379/0 # JWT signing secret (shared with Curator) @@ -42,6 +42,17 @@ JWT_SECRET_KEY=your-secret-key-here # Discogs API DISCOGS_USER_AGENT="Discogsography/1.0 +https://github.com/SimplicityGuy/discogsography" +# OAuth token encryption (Fernet symmetric key — generate with: +# python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') +OAUTH_ENCRYPTION_KEY=your-fernet-key-here + +# Optional — CORS +CORS_ORIGINS="http://localhost:8003,http://localhost:8006" # Comma-separated allowed origins + +# Optional — Snapshot settings +SNAPSHOT_TTL_DAYS=28 # Default: 28 days +SNAPSHOT_MAX_NODES=100 # Default: 100 nodes per snapshot + # Optional JWT_EXPIRE_MINUTES=1440 # Default: 24 hours LOG_LEVEL=INFO @@ -115,11 +126,12 @@ If a user attempts to start the Discogs OAuth flow before credentials are config ### Authentication -| Method | Path | Auth Required | Description | -| ------ | -------------------- | ------------- | --------------------------- | -| POST | `/api/auth/register` | No | Register a new user account | -| POST | `/api/auth/login` | No | Login and receive JWT token | -| GET | `/api/auth/me` | Yes | Get current user details | +| Method | Path | Auth Required | Rate Limit | Description | +| ------ | -------------------- | ------------- | ---------- | ------------------------------- | +| POST | `/api/auth/register` | No | 3/min | Register a new user account | +| POST | `/api/auth/login` | No | 5/min | Login and receive JWT token | +| POST | `/api/auth/logout` | Yes | — | Revoke JWT token (JTI blacklist) | +| GET | `/api/auth/me` | Yes | — | Get current user details | ### Discogs OAuth @@ -134,20 +146,20 @@ If a user attempts to start the Discogs OAuth flow before credentials are config All graph query endpoints are served by the API service and consumed by the Explore frontend. -| Method | Path | Auth Required | Description | -| ------ | --------------------- | ------------- | ------------------------------------ | -| GET | `/api/autocomplete` | No | Search entities with autocomplete | -| GET | `/api/explore` | No | Get center node with category counts | -| GET | `/api/expand` | No | Expand a category node (paginated) | -| GET | `/api/node/{node_id}` | No | Get full details for a node | -| GET | `/api/trends` | No | Get time-series release counts | +| Method | Path | Auth Required | Rate Limit | Description | +| ------ | --------------------- | ------------- | ---------- | ------------------------------------ | +| GET | `/api/autocomplete` | No | 30/min | Search entities with autocomplete | +| GET | `/api/explore` | No | — | Get center node with category counts | +| GET | `/api/expand` | No | — | Expand a category node (paginated) | +| GET | `/api/node/{node_id}` | No | — | Get full details for a node | +| GET | `/api/trends` | No | — | Get time-series release counts | ### Collection Sync -| Method | Path | Auth Required | Description | -| ------ | ------------------ | ------------- | ------------------------------- | -| POST | `/api/sync` | Yes | Trigger a full Discogs sync | -| GET | `/api/sync/status` | Yes | Get sync history (last 10 jobs) | +| Method | Path | Auth Required | Rate Limit | Description | +| ------ | ------------------ | ------------- | ---------- | ------------------------------- | +| POST | `/api/sync` | Yes | 2/10min | Trigger a full Discogs sync | +| GET | `/api/sync/status` | Yes | — | Get sync history (last 10 jobs) | ### User Collection @@ -167,7 +179,7 @@ Save and restore graph exploration states as shareable URLs. | Method | Path | Auth Required | Description | | ------ | ----------------------- | ------------- | --------------------------- | -| POST | `/api/snapshot` | No | Save current graph snapshot | +| POST | `/api/snapshot` | Yes | Save current graph snapshot | | GET | `/api/snapshot/{token}` | No | Restore a saved snapshot | ### Health @@ -221,10 +233,16 @@ The API service uses the following tables (created by schema-init): ## Security -- Passwords hashed with PBKDF2-SHA256 (100,000 iterations, random 32-byte salt) -- JWT signatures use `hmac.compare_digest` for constant-time comparison -- OAuth state stored in Redis with TTL to prevent replay attacks -- All endpoints run as non-root container user (UID 1000) +- **Passwords**: PBKDF2-SHA256 (100,000 iterations, random 32-byte salt) +- **Constant-time auth**: Login and registration use constant-time comparison to prevent user enumeration via timing attacks +- **Blind registration**: Duplicate email registration returns the same 201 response to prevent enumeration +- **JWT revocation**: Logout blacklists the JWT's `jti` claim in Redis with TTL matching the token expiry +- **OAuth tokens encrypted at rest**: Discogs OAuth access tokens are encrypted with Fernet symmetric encryption before database storage (`OAUTH_ENCRYPTION_KEY`) +- **Rate limiting**: register (3/min), login (5/min), sync (2/10min), autocomplete (30/min) via slowapi; per-user sync cooldown (600s) in Redis +- **Security response headers**: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` +- **CORS**: Configurable via `CORS_ORIGINS` env var (disabled by default) +- **Snapshots require auth**: `POST /api/snapshot` requires a valid JWT +- **Container**: All endpoints run as non-root container user (UID 1000) ## Monitoring diff --git a/api/api.py b/api/api.py index d8d93c7f..3d3a6e46 100644 --- a/api/api.py +++ b/api/api.py @@ -10,18 +10,23 @@ import json import os from pathlib import Path +import secrets from typing import Annotated, Any -from fastapi import Depends, FastAPI, HTTPException, status +from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ORJSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from psycopg.rows import dict_row from pydantic import BaseModel import redis.asyncio as aioredis +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded import structlog import uvicorn +from api.auth import encrypt_oauth_token +from api.limiter import limiter from api.models import LoginRequest, RegisterRequest import api.routers.explore as _explore_router import api.routers.snapshot as _snapshot_router @@ -106,6 +111,11 @@ def _verify_password(plain_password: str, hashed_password: str) -> bool: return False +# Pre-computed dummy hash for timing attack protection in login (H4) +# Ensures user-not-found path takes the same time as wrong-password path +_DUMMY_HASH: str = _hash_password("__dummy_password_for_timing__") + + def _create_access_token(user_id: str, email: str) -> tuple[str, int]: """Create a HS256 JWT access token. Returns (token, expires_in_seconds).""" if _config is None: @@ -118,6 +128,7 @@ def _create_access_token(user_id: str, email: str) -> tuple[str, int]: "email": email, "exp": int(expire.timestamp()), "iat": int(datetime.now(UTC).timestamp()), + "jti": secrets.token_hex(16), } header = _b64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) @@ -169,6 +180,16 @@ async def _get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token", ) + # Check jti blacklist (revoked tokens via logout) + jti: str | None = payload.get("jti") + if jti and _redis: + revoked = await _redis.get(f"revoked:jti:{jti}") + if revoked: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has been revoked", + headers={"WWW-Authenticate": "Bearer"}, + ) return payload except ValueError as exc: raise HTTPException( @@ -207,22 +228,28 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: # pragma: no cover await _pool.initialize() logger.info("💾 Database pool initialized") - # Initialize Redis for OAuth state storage + # Initialize Redis for OAuth state storage and token blacklist _redis = await aioredis.from_url(_config.redis_url, decode_responses=True) - logger.info("🔴 Redis connected", url=_config.redis_url) + redis_host = _config.redis_url.split("@")[-1] if "@" in _config.redis_url else _config.redis_url.split("://")[-1] + logger.info("🔴 Redis connected", host=redis_host) if _config.neo4j_address and _config.neo4j_username and _config.neo4j_password: _neo4j = AsyncResilientNeo4jDriver( uri=_config.neo4j_address, auth=(_config.neo4j_username, _config.neo4j_password), max_retries=5, - encrypted=False, + encrypted=False, # M3: Set encrypted=True in production with TLS-enabled Neo4j ) logger.info("🔗 Neo4j driver initialized") jwt_secret_for_neo4j = _config.jwt_secret_key if _config.neo4j_address else None - _sync_router.configure(_pool, _neo4j, _config, _running_syncs) + _sync_router.configure(_pool, _neo4j, _config, _running_syncs, _redis) _explore_router.configure(_neo4j, jwt_secret_for_neo4j) _user_router.configure(_neo4j, jwt_secret_for_neo4j) + _snapshot_router.configure( + jwt_secret=_config.jwt_secret_key, + ttl_days=_config.snapshot_ttl_days, + max_nodes=_config.snapshot_max_nodes, + ) logger.info("✅ API service ready", port=API_PORT) yield @@ -242,6 +269,10 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: # pragma: no cover logger.info("✅ API service stopped") +# Read CORS origins at module load time (config not available yet at this point) +_cors_origins_raw = os.environ.get("CORS_ORIGINS", "") +_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()] if _cors_origins_raw else None + app = FastAPI( title="Discogsography API", version="0.1.0", @@ -250,13 +281,28 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: # pragma: no cover lifespan=lifespan, ) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type] + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_cors_origins or ["http://localhost:3000", "http://localhost:8003"], allow_methods=["*"], allow_headers=["*"], ) + +@app.middleware("http") +async def security_headers(request: Request, call_next: Any) -> Any: + """Add security headers to all responses.""" + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + return response + + app.include_router(_sync_router.router) app.include_router(_explore_router.router) app.include_router(_snapshot_router.router) @@ -270,7 +316,8 @@ async def health_check() -> ORJSONResponse: @app.post("/api/auth/register", status_code=status.HTTP_201_CREATED) -async def register(request: RegisterRequest) -> ORJSONResponse: +@limiter.limit("3/minute") +async def register(request: Request, body: RegisterRequest) -> ORJSONResponse: # noqa: ARG001 """Register a new user account.""" if _pool is None: raise HTTPException( @@ -278,7 +325,7 @@ async def register(request: RegisterRequest) -> ORJSONResponse: detail="Service not ready", ) - hashed_password = _hash_password(request.password) + hashed_password = _hash_password(body.password) try: async with _pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur: @@ -288,16 +335,18 @@ async def register(request: RegisterRequest) -> ORJSONResponse: VALUES (%s, %s) RETURNING id, email, is_active, created_at """, - (request.email, hashed_password), + (body.email, hashed_password), ) row = await cur.fetchone() except Exception as exc: exc_str = str(exc).lower() if "unique" in exc_str or "duplicate" in exc_str: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Email address already registered", - ) from exc + # L1: Return same response for duplicate email to prevent user enumeration + logger.info("i Registration attempt for existing email (blind)") + return ORJSONResponse( + content={"message": "Registration processed"}, + status_code=status.HTTP_201_CREATED, + ) logger.error("❌ Registration failed", error=str(exc)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -310,20 +359,16 @@ async def register(request: RegisterRequest) -> ORJSONResponse: detail="Registration failed", ) - logger.info("✅ User registered", email=request.email) + logger.info("✅ User registered", email=body.email) return ORJSONResponse( - content={ - "id": str(row["id"]), - "email": row["email"], - "is_active": row["is_active"], - "created_at": row["created_at"].isoformat(), - }, + content={"message": "Registration processed"}, status_code=status.HTTP_201_CREATED, ) @app.post("/api/auth/login") -async def login(request: LoginRequest) -> ORJSONResponse: +@limiter.limit("5/minute") +async def login(request: Request, body: LoginRequest) -> ORJSONResponse: # noqa: ARG001 """Authenticate and receive a JWT access token.""" if _pool is None or _config is None: raise HTTPException( @@ -334,11 +379,20 @@ async def login(request: LoginRequest) -> ORJSONResponse: async with _pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur: await cur.execute( "SELECT id, email, hashed_password, is_active FROM users WHERE email = %s", - (request.email,), + (body.email,), ) user = await cur.fetchone() - if user is None or not user["is_active"] or not _verify_password(request.password, user["hashed_password"]): + # H4: Constant-time check to prevent user enumeration via timing + if user is None: + _verify_password(body.password, _DUMMY_HASH) # consume same time as real verify + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user["is_active"] or not _verify_password(body.password, user["hashed_password"]): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", @@ -346,7 +400,7 @@ async def login(request: LoginRequest) -> ORJSONResponse: ) access_token, expires_in = _create_access_token(str(user["id"]), user["email"]) - logger.info("✅ User logged in", email=request.email) + logger.info("✅ User logged in", email=body.email) return ORJSONResponse( content={ @@ -357,6 +411,21 @@ async def login(request: LoginRequest) -> ORJSONResponse: ) +@app.post("/api/auth/logout") +async def logout( + current_user: Annotated[dict[str, Any], Depends(_get_current_user)], +) -> ORJSONResponse: + """Logout and revoke the current JWT token.""" + if _redis: + jti: str | None = current_user.get("jti") + exp: int | None = current_user.get("exp") + if jti: + now = int(datetime.now(UTC).timestamp()) + ttl = max((exp - now), 60) if exp else 3600 + await _redis.setex(f"revoked:jti:{jti}", ttl, "1") + return ORJSONResponse(content={"logged_out": True}) + + @app.get("/api/auth/me") async def get_me( current_user: Annotated[dict[str, Any], Depends(_get_current_user)], @@ -546,8 +615,12 @@ async def verify_discogs( """, ( user_id, - access_data["oauth_token"], - access_data["oauth_token_secret"], + encrypt_oauth_token(access_data["oauth_token"], _config.oauth_encryption_key) + if _config.oauth_encryption_key + else access_data["oauth_token"], + encrypt_oauth_token(access_data["oauth_token_secret"], _config.oauth_encryption_key) + if _config.oauth_encryption_key + else access_data["oauth_token_secret"], discogs_username, discogs_user_id, ), diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000..d7cf3f23 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,59 @@ +"""Shared JWT authentication utilities.""" + +import base64 +from datetime import UTC, datetime +import hashlib +import hmac +import json +from typing import Any + + +def b64url_encode(data: bytes) -> str: + """Base64url encode bytes without padding.""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def b64url_decode(data: str) -> bytes: + """Base64url decode a string, adding padding as needed.""" + padding = 4 - len(data) % 4 + if padding != 4: + data += "=" * padding + return base64.urlsafe_b64decode(data) + + +def decode_token(token: str, secret: str) -> dict[str, Any]: + """Decode and verify a HS256 JWT. Raises ValueError on failure.""" + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Invalid token format") + header_b64, body_b64, sig_b64 = parts + signing_input = f"{header_b64}.{body_b64}".encode("ascii") + expected_sig = b64url_encode(hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()) + if not hmac.compare_digest(sig_b64, expected_sig): + raise ValueError("Invalid token signature") + payload: dict[str, Any] = json.loads(b64url_decode(body_b64)) + exp = payload.get("exp") + if exp and datetime.fromtimestamp(int(exp), UTC) < datetime.now(UTC): + raise ValueError("Token has expired") + return payload + + +def encrypt_oauth_token(token: str, key: str) -> str: + """Encrypt an OAuth token using Fernet symmetric encryption.""" + from cryptography.fernet import Fernet + + f = Fernet(key.encode("ascii")) + return f.encrypt(token.encode("utf-8")).decode("ascii") + + +def decrypt_oauth_token(token: str, key: str | None) -> str: + """Decrypt an OAuth token, falling back to plaintext for migration.""" + if not key: + return token + from cryptography.fernet import Fernet, InvalidToken + + try: + f = Fernet(key.encode("ascii")) + return f.decrypt(token.encode("ascii")).decode("utf-8") + except (InvalidToken, Exception): + return token # fallback to plaintext (migration path) diff --git a/api/limiter.py b/api/limiter.py new file mode 100644 index 00000000..7f169790 --- /dev/null +++ b/api/limiter.py @@ -0,0 +1,7 @@ +"""Rate limiter singleton for the API service.""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + + +limiter = Limiter(key_func=get_remote_address) diff --git a/api/pyproject.toml b/api/pyproject.toml index 4aabca26..42a3de3c 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -23,7 +23,9 @@ dependencies = [ "orjson>=3.9.0", "psycopg[binary]>=3.1.0", "pydantic>=2.10.5", + "cryptography>=43.0.0", "redis[hiredis]>=6.2.0", + "slowapi>=0.1.9", "structlog>=24.0.0", "uvicorn[standard]>=0.34.0", ] diff --git a/api/routers/explore.py b/api/routers/explore.py index 020dca2e..33674a55 100644 --- a/api/routers/explore.py +++ b/api/routers/explore.py @@ -1,19 +1,16 @@ """Explore endpoints — migrated from explore service.""" import asyncio -import base64 from collections import OrderedDict -from datetime import UTC, datetime -import hashlib -import hmac -import json from typing import Annotated, Any -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import ORJSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import structlog +from api.auth import decode_token +from api.limiter import limiter from api.queries.neo4j_queries import ( AUTOCOMPLETE_DISPATCH, COUNT_DISPATCH, @@ -39,38 +36,15 @@ def configure(neo4j: Any, jwt_secret: str | None) -> None: _jwt_secret = jwt_secret -def _b64url_decode(s: str) -> bytes: - padding = 4 - len(s) % 4 - if padding != 4: - s += "=" * padding - return base64.urlsafe_b64decode(s) - - -def _verify_jwt(token: str, secret: str) -> dict[str, Any] | None: - parts = token.split(".") - if len(parts) != 3: - return None - header_b64, body_b64, sig_b64 = parts - signing_input = f"{header_b64}.{body_b64}".encode("ascii") - expected_sig = base64.urlsafe_b64encode(hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()).rstrip(b"=").decode("ascii") - if not hmac.compare_digest(sig_b64, expected_sig): - return None - try: - payload: dict[str, Any] = json.loads(_b64url_decode(body_b64)) - except Exception: - return None - exp = payload.get("exp") - if exp and datetime.fromtimestamp(int(exp), UTC) < datetime.now(UTC): - return None - return payload - - async def _get_optional_user( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_security)], ) -> dict[str, Any] | None: if credentials is None or _jwt_secret is None: return None - return _verify_jwt(credentials.credentials, _jwt_secret) + try: + return decode_token(credentials.credentials, _jwt_secret) + except ValueError: + return None _autocomplete_cache: OrderedDict[tuple[str, str, int], list[dict[str, Any]]] = OrderedDict() @@ -112,7 +86,9 @@ def _build_categories(entity_type: str, result: dict[str, Any]) -> list[dict[str @router.get("/api/autocomplete") +@limiter.limit("30/minute") async def autocomplete( + request: Request, # noqa: ARG001 q: str = Query(..., min_length=2), type: str = Query("artist"), limit: int = Query(10, ge=1, le=50), diff --git a/api/routers/snapshot.py b/api/routers/snapshot.py index 097f3a38..319e0a91 100644 --- a/api/routers/snapshot.py +++ b/api/routers/snapshot.py @@ -1,18 +1,48 @@ """Snapshot endpoints — migrated from explore service.""" -from fastapi import APIRouter +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import ORJSONResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from api.auth import decode_token from api.models import SnapshotRequest, SnapshotResponse, SnapshotRestoreResponse from api.snapshot_store import SnapshotStore router = APIRouter() _snapshot_store = SnapshotStore() +_security = HTTPBearer() +_jwt_secret: str | None = None + + +def configure(jwt_secret: str, ttl_days: int = 28, max_nodes: int = 100) -> None: + global _snapshot_store, _jwt_secret + _snapshot_store = SnapshotStore(ttl_days=ttl_days, max_nodes=max_nodes) + _jwt_secret = jwt_secret + + +async def _get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(_security)], +) -> dict[str, Any]: + if _jwt_secret is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Service not configured") + try: + return decode_token(credentials.credentials, _jwt_secret) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc @router.post("/api/snapshot", status_code=201) -async def save_snapshot(body: SnapshotRequest) -> ORJSONResponse: +async def save_snapshot( + body: SnapshotRequest, + _current_user: Annotated[dict[str, Any], Depends(_get_current_user)], +) -> ORJSONResponse: if len(body.nodes) > _snapshot_store.max_nodes: return ORJSONResponse(content={"error": f"Too many nodes: maximum is {_snapshot_store.max_nodes}"}, status_code=422) nodes = [n.model_dump() for n in body.nodes] diff --git a/api/routers/sync.py b/api/routers/sync.py index decf917e..9a217be9 100644 --- a/api/routers/sync.py +++ b/api/routers/sync.py @@ -1,20 +1,17 @@ """Sync endpoints — migrated from curator service.""" import asyncio -import base64 -from datetime import UTC, datetime -import hashlib -import hmac -import json from typing import Annotated, Any from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import ORJSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from psycopg.rows import dict_row import structlog +from api.auth import decode_token +from api.limiter import limiter from api.syncer import run_full_sync from common import AsyncPostgreSQLPool, AsyncResilientNeo4jDriver @@ -27,6 +24,7 @@ _pool: AsyncPostgreSQLPool | None = None _neo4j: AsyncResilientNeo4jDriver | None = None _config: Any = None +_redis: Any = None _running_syncs: dict[str, asyncio.Task[Any]] = {} @@ -35,37 +33,20 @@ def configure( neo4j: AsyncResilientNeo4jDriver | None, config: Any, running_syncs: dict[str, asyncio.Task[Any]], + redis: Any = None, ) -> None: - global _pool, _neo4j, _config, _running_syncs + global _pool, _neo4j, _config, _running_syncs, _redis _pool = pool _neo4j = neo4j _config = config _running_syncs = running_syncs + _redis = redis async def _verify_token(token: str) -> dict[str, Any]: if _config is None: raise ValueError("Service not initialized") - parts = token.split(".") - if len(parts) != 3: - raise ValueError("Invalid token") - header_b64, body_b64, sig_b64 = parts - signing_input = f"{header_b64}.{body_b64}".encode("ascii") - - def _b64url_encode(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - - expected_sig = _b64url_encode(hmac.new(_config.jwt_secret_key.encode("utf-8"), signing_input, hashlib.sha256).digest()) - if not hmac.compare_digest(sig_b64, expected_sig): - raise ValueError("Invalid token signature") - padding = 4 - len(body_b64) % 4 - if padding != 4: - body_b64 += "=" * padding - payload: dict[str, Any] = json.loads(base64.urlsafe_b64decode(body_b64)) - exp = payload.get("exp") - if exp and datetime.fromtimestamp(int(exp), UTC) < datetime.now(UTC): - raise ValueError("Token expired") - return payload + return decode_token(token, _config.jwt_secret_key) async def _get_current_user( @@ -88,7 +69,9 @@ async def _get_current_user( @router.post("/api/sync", status_code=status.HTTP_202_ACCEPTED) +@limiter.limit("2/10minute") async def trigger_sync( + request: Request, # noqa: ARG001 — required by slowapi rate limiter current_user: Annotated[dict[str, Any], Depends(_get_current_user)], ) -> ORJSONResponse: if _pool is None or _neo4j is None or _config is None: @@ -96,6 +79,17 @@ async def trigger_sync( user_id = current_user.get("sub") if not user_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + # Redis-based per-user sync cooldown (prevents rapid re-triggers) + if _redis: + cooldown_key = f"sync:cooldown:{user_id}" + in_cooldown = await _redis.get(cooldown_key) + if in_cooldown: + return ORJSONResponse( + content={"status": "cooldown", "message": "Sync rate limited. Please wait before triggering again."}, + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + if user_id in _running_syncs and not _running_syncs[user_id].done(): async with _pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur: await cur.execute( @@ -124,9 +118,15 @@ async def trigger_sync( pg_pool=_pool, neo4j_driver=_neo4j, discogs_user_agent=_config.discogs_user_agent, + oauth_encryption_key=getattr(_config, "oauth_encryption_key", None), ) ) _running_syncs[user_id] = task + + # Set per-user cooldown to prevent rapid re-triggers + if _redis: + await _redis.setex(f"sync:cooldown:{user_id}", 600, "1") + logger.info("🔄 Sync triggered", user_id=user_id, sync_id=sync_id) return ORJSONResponse(content={"sync_id": sync_id, "status": "started"}, status_code=status.HTTP_202_ACCEPTED) diff --git a/api/routers/user.py b/api/routers/user.py index 68d266e5..31b0a097 100644 --- a/api/routers/user.py +++ b/api/routers/user.py @@ -1,10 +1,5 @@ """User endpoints — migrated from explore service.""" -import base64 -from datetime import UTC, datetime -import hashlib -import hmac -import json from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -12,6 +7,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import structlog +from api.auth import decode_token from api.queries.user_queries import ( check_releases_user_status, get_user_collection, @@ -36,38 +32,15 @@ def configure(neo4j: Any, jwt_secret: str | None) -> None: _jwt_secret = jwt_secret -def _b64url_decode(s: str) -> bytes: - padding = 4 - len(s) % 4 - if padding != 4: - s += "=" * padding - return base64.urlsafe_b64decode(s) - - -def _verify_jwt(token: str, secret: str) -> dict[str, Any] | None: - parts = token.split(".") - if len(parts) != 3: - return None - header_b64, body_b64, sig_b64 = parts - signing_input = f"{header_b64}.{body_b64}".encode("ascii") - expected_sig = base64.urlsafe_b64encode(hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()).rstrip(b"=").decode("ascii") - if not hmac.compare_digest(sig_b64, expected_sig): - return None - try: - payload: dict[str, Any] = json.loads(_b64url_decode(body_b64)) - except Exception: - return None - exp = payload.get("exp") - if exp and datetime.fromtimestamp(int(exp), UTC) < datetime.now(UTC): - return None - return payload - - async def _get_optional_user( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_security)], ) -> dict[str, Any] | None: if credentials is None or _jwt_secret is None: return None - return _verify_jwt(credentials.credentials, _jwt_secret) + try: + return decode_token(credentials.credentials, _jwt_secret) + except ValueError: + return None async def _require_user( @@ -77,10 +50,12 @@ async def _require_user( raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Personalized endpoints not enabled") if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required", headers={"WWW-Authenticate": "Bearer"}) - payload = _verify_jwt(credentials.credentials, _jwt_secret) - if payload is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}) - return payload + try: + return decode_token(credentials.credentials, _jwt_secret) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"} + ) from exc @router.get("/api/user/collection") @@ -140,6 +115,8 @@ async def user_release_status( release_ids = [rid.strip() for rid in ids.split(",") if rid.strip()] if not release_ids: return ORJSONResponse(content={"status": {}}) + if len(release_ids) > 100: + return ORJSONResponse(content={"error": "Too many IDs: maximum is 100"}, status_code=422) if not _neo4j_driver or current_user is None: return ORJSONResponse(content={"status": {rid: {"in_collection": False, "in_wantlist": False} for rid in release_ids}}) user_id: str = current_user.get("sub", "") diff --git a/api/services/discogs.py b/api/services/discogs.py index 860cb9e2..79e494bd 100644 --- a/api/services/discogs.py +++ b/api/services/discogs.py @@ -122,7 +122,8 @@ async def request_oauth_token( response = await client.get(url, headers=headers) if response.status_code != 200: - raise DiscogsOAuthError(f"Failed to get request token: {response.status_code} {response.text}") + logger.debug("Discogs API error body", status=response.status_code, body=response.text) + raise DiscogsOAuthError(f"Failed to get request token: HTTP {response.status_code}") params = dict(urllib.parse.parse_qsl(response.text)) if "oauth_token" not in params or "oauth_token_secret" not in params: @@ -186,7 +187,8 @@ async def exchange_oauth_verifier( response = await client.post(url, headers=headers) if response.status_code != 200: - raise DiscogsOAuthError(f"Failed to exchange verifier: {response.status_code} {response.text}") + logger.debug("Discogs API error body", status=response.status_code, body=response.text) + raise DiscogsOAuthError(f"Failed to exchange verifier: HTTP {response.status_code}") params = dict(urllib.parse.parse_qsl(response.text)) if "oauth_token" not in params or "oauth_token_secret" not in params: @@ -240,7 +242,8 @@ async def fetch_discogs_identity( response = await client.get(url, headers=headers) if response.status_code != 200: - raise DiscogsOAuthError(f"Failed to fetch Discogs identity: {response.status_code} {response.text}") + logger.debug("Discogs API error body", status=response.status_code, body=response.text) + raise DiscogsOAuthError(f"Failed to fetch Discogs identity: HTTP {response.status_code}") identity: dict[str, Any] = response.json() logger.info("✅ Discogs identity fetched", username=identity.get("username")) diff --git a/api/snapshot_store.py b/api/snapshot_store.py index 393f1eb9..5aa3e6d3 100644 --- a/api/snapshot_store.py +++ b/api/snapshot_store.py @@ -1,7 +1,6 @@ """In-memory snapshot store with TTL eviction for graph state persistence.""" from datetime import UTC, datetime, timedelta -import os import secrets from typing import Any @@ -9,10 +8,11 @@ class SnapshotStore: """Thread-safe in-memory store for graph snapshots with TTL eviction.""" - def __init__(self) -> None: + def __init__(self, ttl_days: int = 28, max_nodes: int = 100, max_entries: int = 1000) -> None: self._store: dict[str, dict[str, Any]] = {} - self._ttl_days: int = int(os.environ.get("SNAPSHOT_TTL_DAYS", "28")) - self._max_nodes: int = int(os.environ.get("SNAPSHOT_MAX_NODES", "100")) + self._ttl_days: int = ttl_days + self._max_nodes: int = max_nodes + self._max_entries: int = max_entries @property def ttl_days(self) -> int: diff --git a/api/syncer.py b/api/syncer.py index a1d495ef..94887290 100644 --- a/api/syncer.py +++ b/api/syncer.py @@ -24,6 +24,7 @@ from psycopg.rows import dict_row import structlog +from api.auth import decrypt_oauth_token from common import AsyncPostgreSQLPool, AsyncResilientNeo4jDriver @@ -414,6 +415,7 @@ async def run_full_sync( pg_pool: AsyncPostgreSQLPool, neo4j_driver: AsyncResilientNeo4jDriver, discogs_user_agent: str, + oauth_encryption_key: str | None = None, ) -> dict[str, Any]: """Run a full collection + wantlist sync for a user. @@ -443,6 +445,10 @@ async def run_full_sync( if not token: raise ValueError("No Discogs OAuth token found for user. Please connect Discogs first.") + # Decrypt OAuth tokens if encryption key is configured + access_token_value = decrypt_oauth_token(token["access_token"], oauth_encryption_key) + access_secret_value = decrypt_oauth_token(token["access_secret"], oauth_encryption_key) + # Fetch app credentials await cur.execute("SELECT key, value FROM app_config WHERE key IN ('discogs_consumer_key', 'discogs_consumer_secret')") config_rows = await cur.fetchall() @@ -459,8 +465,8 @@ async def run_full_sync( discogs_username=discogs_username, consumer_key=app_config["discogs_consumer_key"], consumer_secret=app_config["discogs_consumer_secret"], - access_token=token["access_token"], - token_secret=token["access_secret"], + access_token=access_token_value, + token_secret=access_secret_value, user_agent=discogs_user_agent, pg_pool=pg_pool, neo4j_driver=neo4j_driver, @@ -472,8 +478,8 @@ async def run_full_sync( discogs_username=discogs_username, consumer_key=app_config["discogs_consumer_key"], consumer_secret=app_config["discogs_consumer_secret"], - access_token=token["access_token"], - token_secret=token["access_secret"], + access_token=access_token_value, + token_secret=access_secret_value, user_agent=discogs_user_agent, pg_pool=pg_pool, neo4j_driver=neo4j_driver, diff --git a/codecov.yml b/codecov.yml index ce2fee89..51b7b514 100644 --- a/codecov.yml +++ b/codecov.yml @@ -31,13 +31,41 @@ comment: # Flag configuration for different coverage types flags: - python: + api: paths: - - "**/*.py" + - "api/**" carryforward: true - rust: + curator: paths: - - "extractor/**/*.rs" + - "curator/**" + carryforward: true + common: + paths: + - "common/**" + carryforward: true + dashboard: + paths: + - "dashboard/**" + carryforward: true + explore: + paths: + - "explore/**" + carryforward: true + graphinator: + paths: + - "graphinator/**" + carryforward: true + schema-init: + paths: + - "schema-init/**" + carryforward: true + tableinator: + paths: + - "tableinator/**" + carryforward: true + extractor: + paths: + - "extractor/**" carryforward: true e2e-chromium: paths: @@ -77,6 +105,14 @@ component_management: - type: project target: auto individual_components: + - component_id: api + name: API + paths: + - "api/**" + - component_id: curator + name: Curator + paths: + - "curator/**" - component_id: dashboard name: Dashboard paths: @@ -93,6 +129,10 @@ component_management: name: Graphinator paths: - "graphinator/**" + - component_id: schema-init + name: Schema-Init + paths: + - "schema-init/**" - component_id: tableinator name: Tableinator paths: diff --git a/common/config.py b/common/config.py index 8ae3c907..281909e7 100644 --- a/common/config.py +++ b/common/config.py @@ -5,12 +5,12 @@ from os import getenv from pathlib import Path import sys -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import warnings if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Sequence # pragma: no cover import orjson import structlog @@ -90,10 +90,10 @@ def from_env(cls) -> "GraphinatorConfig": raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") return cls( - amqp_connection=amqp_connection, # type: ignore - neo4j_address=neo4j_address, # type: ignore - neo4j_username=neo4j_username, # type: ignore - neo4j_password=neo4j_password, # type: ignore + amqp_connection=cast("str", amqp_connection), + neo4j_address=cast("str", neo4j_address), + neo4j_username=cast("str", neo4j_username), + neo4j_password=cast("str", neo4j_password), ) @@ -132,11 +132,11 @@ def from_env(cls) -> "TableinatorConfig": raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") return cls( - amqp_connection=amqp_connection, # type: ignore - postgres_address=postgres_address, # type: ignore - postgres_username=postgres_username, # type: ignore - postgres_password=postgres_password, # type: ignore - postgres_database=postgres_database, # type: ignore + amqp_connection=cast("str", amqp_connection), + postgres_address=cast("str", postgres_address), + postgres_username=cast("str", postgres_username), + postgres_password=cast("str", postgres_password), + postgres_database=cast("str", postgres_database), ) @@ -367,6 +367,10 @@ class ApiConfig: neo4j_address: str | None = None neo4j_username: str | None = None neo4j_password: str | None = None + cors_origins: list[str] | None = None + snapshot_ttl_days: int = 28 + snapshot_max_nodes: int = 100 + oauth_encryption_key: str | None = None @classmethod def from_env(cls) -> "ApiConfig": @@ -394,6 +398,8 @@ def from_env(cls) -> "ApiConfig": redis_url = getenv("REDIS_URL", "redis://redis:6379/0") jwt_algorithm = getenv("JWT_ALGORITHM", "HS256") + if jwt_algorithm != "HS256": + raise ValueError(f"Unsupported JWT algorithm: {jwt_algorithm}. Only HS256 is supported.") jwt_expire_minutes_str = getenv("JWT_EXPIRE_MINUTES", "30") try: jwt_expire_minutes = int(jwt_expire_minutes_str) @@ -407,12 +413,29 @@ def from_env(cls) -> "ApiConfig": neo4j_username = getenv("NEO4J_USERNAME") or None neo4j_password = getenv("NEO4J_PASSWORD") or None + cors_origins_env = getenv("CORS_ORIGINS") + cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else None + + snapshot_ttl_days_str = getenv("SNAPSHOT_TTL_DAYS", "28") + try: + snapshot_ttl_days = int(snapshot_ttl_days_str) + except ValueError: + snapshot_ttl_days = 28 + + snapshot_max_nodes_str = getenv("SNAPSHOT_MAX_NODES", "100") + try: + snapshot_max_nodes = int(snapshot_max_nodes_str) + except ValueError: + snapshot_max_nodes = 100 + + oauth_encryption_key = getenv("OAUTH_ENCRYPTION_KEY") or None + return cls( - postgres_address=postgres_address, # type: ignore[arg-type] - postgres_username=postgres_username, # type: ignore[arg-type] - postgres_password=postgres_password, # type: ignore[arg-type] - postgres_database=postgres_database, # type: ignore[arg-type] - jwt_secret_key=jwt_secret_key, # type: ignore[arg-type] + postgres_address=cast("str", postgres_address), + postgres_username=cast("str", postgres_username), + postgres_password=cast("str", postgres_password), + postgres_database=cast("str", postgres_database), + jwt_secret_key=cast("str", jwt_secret_key), redis_url=redis_url, jwt_algorithm=jwt_algorithm, jwt_expire_minutes=jwt_expire_minutes, @@ -420,6 +443,10 @@ def from_env(cls) -> "ApiConfig": neo4j_address=neo4j_address, neo4j_username=neo4j_username, neo4j_password=neo4j_password, + cors_origins=cors_origins, + snapshot_ttl_days=snapshot_ttl_days, + snapshot_max_nodes=snapshot_max_nodes, + oauth_encryption_key=oauth_encryption_key, ) @@ -434,8 +461,8 @@ class CuratorConfig: neo4j_address: str neo4j_username: str neo4j_password: str - jwt_secret_key: str discogs_user_agent: str = "discogsography/1.0 +https://github.com/SimplicityGuy/discogsography" + cors_origins: list[str] | None = None @classmethod def from_env(cls) -> "CuratorConfig": @@ -447,7 +474,6 @@ def from_env(cls) -> "CuratorConfig": neo4j_address = getenv("NEO4J_ADDRESS") neo4j_username = getenv("NEO4J_USERNAME") neo4j_password = getenv("NEO4J_PASSWORD") - jwt_secret_key = getenv("JWT_SECRET_KEY") missing_vars = [] if not postgres_address: @@ -464,8 +490,6 @@ def from_env(cls) -> "CuratorConfig": missing_vars.append("NEO4J_USERNAME") if not neo4j_password: missing_vars.append("NEO4J_PASSWORD") - if not jwt_secret_key: - missing_vars.append("JWT_SECRET_KEY") if missing_vars: raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") @@ -475,16 +499,19 @@ def from_env(cls) -> "CuratorConfig": "discogsography/1.0 +https://github.com/SimplicityGuy/discogsography", ) + cors_origins_env = getenv("CORS_ORIGINS") + cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else None + return cls( - postgres_address=postgres_address, # type: ignore[arg-type] - postgres_username=postgres_username, # type: ignore[arg-type] - postgres_password=postgres_password, # type: ignore[arg-type] - postgres_database=postgres_database, # type: ignore[arg-type] - neo4j_address=neo4j_address, # type: ignore[arg-type] - neo4j_username=neo4j_username, # type: ignore[arg-type] - neo4j_password=neo4j_password, # type: ignore[arg-type] - jwt_secret_key=jwt_secret_key, # type: ignore[arg-type] + postgres_address=cast("str", postgres_address), + postgres_username=cast("str", postgres_username), + postgres_password=cast("str", postgres_password), + postgres_database=cast("str", postgres_database), + neo4j_address=cast("str", neo4j_address), + neo4j_username=cast("str", neo4j_username), + neo4j_password=cast("str", neo4j_password), discogs_user_agent=discogs_user_agent, + cors_origins=cors_origins, ) @@ -523,9 +550,9 @@ def from_env(cls) -> "ExploreConfig": jwt_secret_key = getenv("JWT_SECRET_KEY") or None return cls( - neo4j_address=neo4j_address, # type: ignore - neo4j_username=neo4j_username, # type: ignore - neo4j_password=neo4j_password, # type: ignore + neo4j_address=cast("str", neo4j_address), + neo4j_username=cast("str", neo4j_username), + neo4j_password=cast("str", neo4j_password), jwt_secret_key=jwt_secret_key, ) diff --git a/common/health_server.py b/common/health_server.py index 37394471..d300b04e 100644 --- a/common/health_server.py +++ b/common/health_server.py @@ -6,7 +6,7 @@ import json import logging from threading import Thread -from typing import Any +from typing import Any, cast logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def do_GET(self) -> None: """Handle GET requests.""" if self.path == "/health": # Get health data from the server instance - health_data = self.server.get_health_data() # type: ignore + health_data = cast("HealthServer", self.server).get_health_data() self.send_response(200) self.send_header("Content-Type", "application/json") diff --git a/curator/curator.py b/curator/curator.py index 5c07d470..fc26e66a 100644 --- a/curator/curator.py +++ b/curator/curator.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from datetime import UTC, datetime +import os from pathlib import Path from typing import Any @@ -18,6 +19,9 @@ logger = structlog.get_logger(__name__) +_cors_origins_raw = os.environ.get("CORS_ORIGINS", "") +_cors_origins: list[str] | None = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()] if _cors_origins_raw else None + # Module-level state _pool: AsyncPostgreSQLPool | None = None _neo4j: AsyncResilientNeo4jDriver | None = None @@ -96,8 +100,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], + allow_origins=_cors_origins or ["http://localhost:3000", "http://localhost:8003"], + allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) diff --git a/docs/configuration.md b/docs/configuration.md index 30b35490..9253dddb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -459,7 +459,7 @@ See [Performance Guide](performance-guide.md) for detailed optimization strategi | `CACHE_WARMING_ENABLED` | Pre-warm cache on startup | `true` | No | | `CACHE_WEBHOOK_SECRET` | Secret for cache invalidation webhooks | (none — disabled) | No | -**Used By**: Dashboard only +**Used By**: Dashboard only (for `RABBITMQ_MANAGEMENT_USER`, `CACHE_WARMING_ENABLED`, `CACHE_WEBHOOK_SECRET`); `CORS_ORIGINS` is also supported by the API service — see the [API](#api) section above. **Notes**: @@ -497,6 +497,17 @@ REDIS_URL="redis://localhost:6379/0" JWT_SECRET_KEY="your-secret-key-here" DISCOGS_USER_AGENT="Discogsography/1.0 +https://github.com/SimplicityGuy/discogsography" +# Required — Discogs OAuth token encryption (Fernet symmetric key) +# Generate with: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' +OAUTH_ENCRYPTION_KEY="your-fernet-key-here" + +# Optional — CORS origins (comma-separated; omit to disable CORS) +CORS_ORIGINS="http://localhost:8003,http://localhost:8006" + +# Optional — snapshot settings +SNAPSHOT_TTL_DAYS=28 # Snapshot expiry in days (default: 28) +SNAPSHOT_MAX_NODES=100 # Max nodes per snapshot (default: 100) + # Optional JWT_EXPIRE_MINUTES=1440 LOG_LEVEL=INFO diff --git a/docs/development.md b/docs/development.md index e9ec7bc7..f0c78976 100644 --- a/docs/development.md +++ b/docs/development.md @@ -40,16 +40,29 @@ Discogsography leverages cutting-edge Python tooling for maximum developer produ ``` discogsography/ +├── 🔐 api/ # User auth, graph queries, OAuth, sync trigger +│ ├── api.py # FastAPI application entry point +│ ├── auth.py # JWT helpers and OAuth token encryption +│ ├── limiter.py # Shared slowapi rate-limiter instance +│ ├── setup.py # discogs-setup CLI tool +│ ├── routers/ # FastAPI routers (auth, explore, sync, user, snapshot, oauth) +│ ├── README.md +│ └── __init__.py +├── 🗂️ curator/ # Background Discogs collection/wantlist sync +│ ├── curator.py # FastAPI health-only app +│ ├── syncer.py # Sync logic (collection + wantlist → Neo4j/PostgreSQL) +│ ├── README.md +│ └── __init__.py ├── 📦 common/ # Shared utilities and configuration │ ├── config.py # Centralized configuration management │ ├── health_server.py # Health check endpoint server │ └── __init__.py ├── 📊 dashboard/ # Real-time monitoring dashboard │ ├── dashboard.py # FastAPI backend with WebSocket -│ ├── static/ # Frontend HTML/CSS/JS +│ ├── static/ # Frontend HTML/CSS/JS (Tailwind, SVG gauges) │ │ ├── index.html │ │ ├── styles.css -│ │ └── app.js +│ │ └── dashboard.js │ ├── README.md │ └── __init__.py ├── 📥 extractor/ # Rust-based high-performance extractor @@ -59,9 +72,9 @@ discogsography/ │ ├── tests/ # Rust unit tests │ ├── Cargo.toml # Rust dependencies │ └── README.md -├── 🔍 explore/ # Interactive graph exploration & trends -│ ├── explore.py # FastAPI backend with Neo4j queries -│ ├── static/ # Frontend HTML/CSS/JS +├── 🔍 explore/ # Static frontend for graph exploration UI +│ ├── explore.py # FastAPI static file server (health check only) +│ ├── static/ # Frontend HTML/CSS/JS (D3.js, Plotly.js) │ ├── README.md │ └── __init__.py ├── 🔗 graphinator/ # Neo4j graph database service @@ -253,12 +266,15 @@ uv run pre-commit run --all-files ``` tests/ +├── api/ # API service tests (auth, routers, queries) ├── common/ # Common module tests +├── curator/ # Curator service tests ├── dashboard/ # Dashboard tests │ └── test_dashboard_ui.py # E2E tests with Playwright ├── explore/ # Explore service tests -├── extractor/ # Extractor tests (Rust) ├── graphinator/ # Graphinator tests +├── load/ # Load tests (Locust) +├── schema-init/ # Schema initializer tests └── tableinator/ # Tableinator tests ``` diff --git a/docs/recent-improvements.md b/docs/recent-improvements.md index 1f2833ce..3c070141 100644 --- a/docs/recent-improvements.md +++ b/docs/recent-improvements.md @@ -13,7 +13,65 @@ Last Updated: February 2026 This document tracks recent improvements made to the Discogsography platform, focusing on CI/CD, automation, and development experience enhancements. -## 🆕 Latest Improvements (February 2026) +## 🆕 Latest Improvements (February 2026 — Continued) + +### 🔒 Security Hardening — Issue #71 (February 2026) + +**Overview**: Addressed a set of security findings (issue #71) across the API service. + +#### Changes + +- **OAuth token encryption**: Discogs OAuth access tokens are now encrypted at rest using Fernet symmetric encryption before being stored in PostgreSQL. A new `OAUTH_ENCRYPTION_KEY` env var is required for the API container. +- **Constant-time login**: Login and registration now use constant-time comparison to prevent user enumeration via timing attacks. +- **Blind registration**: Duplicate email registration returns the same `201` response to prevent account enumeration. +- **JWT logout with JTI blacklist**: `POST /api/auth/logout` now revokes the token's `jti` claim in Redis (TTL = token expiry), making logout stateful. +- **Snapshot auth required**: `POST /api/snapshot` now requires a valid JWT token. +- **Rate limiting**: Added SlowAPI rate limits — register (3/min), login (5/min), sync (2/10min), autocomplete (30/min). Per-user sync cooldown (600 s) stored in Redis. +- **Security response headers**: All responses now include `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and `Permissions-Policy`. +- **CORS**: Origins configurable via `CORS_ORIGINS` env var (comma-separated; disabled by default). +- **Input validation**: JWT algorithm validated to be `HS256`; Discogs API response bodies redacted from error messages. + +#### Refactoring + +- Extracted shared JWT helpers to `api/auth.py` (`b64url_encode/decode`, `decode_token`) — removed duplicated implementations from individual routers. +- Added `api/limiter.py` for a shared SlowAPI `Limiter` instance. +- Replaced all `type: ignore` pragmas with proper type narrowing across the codebase. + +--- + +### 👤 Discogs User Integration (February 2026) + +**Overview**: Full Discogs account linking, collection and wantlist sync, and personalised graph exploration. + +#### Features + +- **OAuth 1.0a OOB flow**: Users connect their Discogs account via `GET /api/oauth/authorize/discogs` → `POST /api/oauth/verify/discogs`. State token stored in Redis with TTL. +- **Collection & wantlist sync**: `POST /api/sync` triggers a background job in the Curator service that fetches the user's Discogs collection and wantlist and writes `COLLECTED` / `WANTS` relationships to Neo4j. +- **Sync history**: `GET /api/sync/status` returns the last 10 sync operations with status, item count, and error details. +- **User endpoints**: `/api/user/collection`, `/api/user/wantlist`, `/api/user/recommendations`, `/api/user/collection/stats`, `/api/user/status` for personalised graph data. +- **Operator setup**: Discogs app credentials configured once via the `discogs-setup` CLI bundled in the API container (reads/writes the `app_config` table). + +--- + +### 🏗️ API Consolidation (February 2026) + +**Overview**: All user-facing HTTP endpoints consolidated into the central **API service**. Explore and Curator now expose health-only endpoints. + +#### Before / After + +| Endpoint group | Before | After | +| --------------------- | -------------------------- | ------------------ | +| Graph queries | Explore service (:8006) | API service (:8004) | +| Sync triggers | Curator service (:8010) | API service (:8004) | +| User collection data | (new) | API service (:8004) | + +#### Benefits + +- Single port (8004) for all client-facing API calls — simpler frontend configuration. +- Explore and Curator internal services are now health-only, reducing their attack surface. +- Shared JWT authentication and rate limiting enforced uniformly at the API layer. + +--- ### 🎨 Dashboard UI Redesign (February 2026) diff --git a/docs/usage-examples.md b/docs/usage-examples.md index e28c9b9a..927441d9 100644 --- a/docs/usage-examples.md +++ b/docs/usage-examples.md @@ -521,36 +521,72 @@ WHERE schemaname = 'public' ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; ``` -## 🎵 Discovery Service Examples +## 🔐 API Service Examples -The Discovery service provides AI-powered music intelligence through its REST API. +The API service provides graph exploration endpoints at `http://localhost:8004`. -### Semantic Search +### Graph Exploration ```bash -# Search for similar artists -curl "http://localhost:8005/api/similar-artists?artist=Miles%20Davis&limit=10" +# Search with autocomplete (artist, genre, label, or style) +curl "http://localhost:8004/api/autocomplete?q=miles&type=artist&limit=10" + +# Explore a center node (returns categories with counts) +curl "http://localhost:8004/api/explore?name=Miles%20Davis&type=artist" + +# Expand a category (paginated) +curl "http://localhost:8004/api/expand?node_id=Miles%20Davis&type=artist&category=releases&limit=50&offset=0" + +# Get full details for a node +curl "http://localhost:8004/api/node/1?type=artist" ``` -### Genre Analysis +### Trend Analysis ```bash -# Get genre trends over time -curl "http://localhost:8005/api/genre-trends?genre=Jazz&start_year=1950&end_year=1970" +# Get year-by-year release counts for an entity +curl "http://localhost:8004/api/trends?name=Miles%20Davis&type=artist" +curl "http://localhost:8004/api/trends?name=Jazz&type=genre" +curl "http://localhost:8004/api/trends?name=Blue%20Note&type=label" ``` -### Artist Network +### User Collection (requires JWT authentication) ```bash -# Get artist collaboration network -curl "http://localhost:8005/api/artist-network?artist=David%20Bowie&depth=2" +# Register a user account +curl -X POST "http://localhost:8004/api/auth/register" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "secret"}' + +# Login to receive a JWT token +curl -X POST "http://localhost:8004/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "secret"}' + +# Use the token to query your collection +curl "http://localhost:8004/api/user/collection?limit=50" \ + -H "Authorization: Bearer " + +# Get collection statistics +curl "http://localhost:8004/api/user/collection/stats" \ + -H "Authorization: Bearer " + +# Get recommendations based on your collection +curl "http://localhost:8004/api/user/recommendations?limit=20" \ + -H "Authorization: Bearer " ``` -### Label Analytics +### Graph Snapshots ```bash -# Get label statistics -curl "http://localhost:8005/api/label-stats?label=Blue%20Note" +# Save a graph snapshot (requires authentication) +curl -X POST "http://localhost:8004/api/snapshot" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"nodes": [...], "edges": [...]}' + +# Restore a saved snapshot (public, no auth required) +curl "http://localhost:8004/api/snapshot/" ``` ## 📊 Combining Neo4j and PostgreSQL @@ -614,4 +650,4 @@ AND (data->>'year')::int = 1959; ______________________________________________________________________ -**Last Updated**: 2025-01-15 +**Last Updated**: 2026-02-25 diff --git a/pyproject.toml b/pyproject.toml index e9602946..4db23906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ [project.optional-dependencies] api = [ + "cryptography>=43.0.0", "fastapi>=0.115.6", "httpx>=0.27.0", "neo4j>=6.1.0", @@ -40,6 +41,7 @@ api = [ "psycopg[binary]>=3.1.0", "pydantic>=2.10.5", "redis[hiredis]>=6.2.0", + "slowapi>=0.1.9", "structlog>=24.0.0", "uvicorn[standard]>=0.34.0", ] diff --git a/schema-init/schema_init.py b/schema-init/schema_init.py index d6039126..c58546d6 100755 --- a/schema-init/schema_init.py +++ b/schema-init/schema_init.py @@ -14,6 +14,7 @@ import os import sys from pathlib import Path +from typing import Any import psycopg import structlog @@ -42,7 +43,7 @@ POSTGRES_DATABASE = os.environ.get("POSTGRES_DATABASE", "discogsography") -def _postgres_connection_params() -> dict[str, str | int]: +def _postgres_connection_params() -> dict[str, Any]: """Parse POSTGRES_ADDRESS into psycopg connection params.""" if ":" in POSTGRES_ADDRESS: host, port_str = POSTGRES_ADDRESS.split(":", 1) @@ -62,11 +63,11 @@ def _postgres_connection_params() -> dict[str, str | int]: # ── PostgreSQL ──────────────────────────────────────────────────────────────── -def _ensure_postgres_database(params: dict[str, str | int]) -> None: +def _ensure_postgres_database(params: dict[str, Any]) -> None: """Create the target database if it does not already exist (synchronous).""" admin_params = {**params, "dbname": "postgres"} logger.info("🔧 Ensuring PostgreSQL database exists...", database=POSTGRES_DATABASE) - with psycopg.connect(**admin_params) as conn: # type: ignore[arg-type] + with psycopg.connect(**admin_params) as conn: conn.autocommit = True with conn.cursor() as cursor: cursor.execute( diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 132a2b32..0015f6b3 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -168,12 +168,14 @@ async def mock_lifespan(_app: FastAPI) -> AsyncGenerator[None]: api_module._neo4j = mock_neo4j import api.routers.explore as _explore_router + import api.routers.snapshot as _snapshot_router import api.routers.sync as _sync_router import api.routers.user as _user_router - _sync_router.configure(mock_pool, mock_neo4j, test_api_config, api_module._running_syncs) + _sync_router.configure(mock_pool, mock_neo4j, test_api_config, api_module._running_syncs, mock_redis) _explore_router.configure(mock_neo4j, test_api_config.jwt_secret_key) _user_router.configure(mock_neo4j, test_api_config.jwt_secret_key) + _snapshot_router.configure(jwt_secret=TEST_JWT_SECRET) with TestClient(app, raise_server_exceptions=False) as client: yield client @@ -187,6 +189,18 @@ async def mock_lifespan(_app: FastAPI) -> AsyncGenerator[None]: app.router.lifespan_context = original_lifespan +@pytest.fixture(autouse=True) +def reset_rate_limits() -> Generator[None]: + """Reset slowapi rate limiter storage between tests.""" + yield + try: + from api.limiter import limiter + + limiter._storage.reset() + except Exception: # noqa: S110 + pass + + @pytest.fixture def auth_headers(valid_token: str) -> dict[str, str]: """Authorization headers with a valid bearer token.""" diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 519f2d00..3760f3aa 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -213,8 +213,7 @@ def test_register_success( ) assert response.status_code == 201 data = response.json() - assert data["email"] == TEST_USER_EMAIL - assert "id" in data + assert data["message"] == "Registration processed" def test_register_duplicate_email_409( self, @@ -227,7 +226,8 @@ def test_register_duplicate_email_409( "/api/auth/register", json={"email": "dup@example.com", "password": "Password123!"}, ) - assert response.status_code == 409 + assert response.status_code == 201 + assert response.json()["message"] == "Registration processed" def test_register_generic_db_error_500( self, @@ -850,3 +850,132 @@ def b64url(data: bytes) -> str: token = f"{header}.{body}.{sig}" response = test_client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) assert response.status_code == 401 + + +class TestLogoutEndpoint: + """Tests for POST /api/auth/logout.""" + + def test_logout_no_auth_returns_401(self, test_client: TestClient) -> None: + response = test_client.post("/api/auth/logout") + assert response.status_code in (401, 403) + + def test_logout_success(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + response = test_client.post("/api/auth/logout", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["logged_out"] is True + + def test_logout_revokes_jti_in_redis(self, test_client: TestClient, mock_redis: AsyncMock) -> None: + from api.api import _create_access_token + + token, _ = _create_access_token(TEST_USER_ID, TEST_USER_EMAIL) + response = test_client.post("/api/auth/logout", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + mock_redis.setex.assert_awaited_once() + assert mock_redis.setex.call_args[0][0].startswith("revoked:jti:") + + def test_logout_redis_none_succeeds_gracefully(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + import api.api as api_module + + original = api_module._redis + api_module._redis = None + try: + response = test_client.post("/api/auth/logout", headers=auth_headers) + assert response.status_code == 200 + finally: + api_module._redis = original + + +class TestJtiBlacklist: + """Tests for JTI blacklist check in _get_current_user.""" + + def test_revoked_jti_returns_401(self, test_client: TestClient, mock_redis: AsyncMock) -> None: + from api.api import _create_access_token + + token, _ = _create_access_token(TEST_USER_ID, TEST_USER_EMAIL) + mock_redis.get.return_value = "1" # jti is revoked + response = test_client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 401 + + def test_non_revoked_jti_allows_access( + self, test_client: TestClient, mock_redis: AsyncMock, mock_cur: AsyncMock, auth_headers: dict[str, str] + ) -> None: + from datetime import UTC, datetime + + mock_redis.get.return_value = None # not revoked + mock_cur.fetchone.return_value = { + "id": TEST_USER_ID, + "email": TEST_USER_EMAIL, + "is_active": True, + "created_at": datetime.now(UTC), + } + response = test_client.get("/api/auth/me", headers=auth_headers) + assert response.status_code == 200 + + def test_create_access_token_includes_jti(self, test_client: TestClient) -> None: # noqa: ARG002 + from api.api import _create_access_token, _decode_access_token + + token, _ = _create_access_token(TEST_USER_ID, TEST_USER_EMAIL) + payload = _decode_access_token(token) + assert "jti" in payload + assert isinstance(payload["jti"], str) + assert len(payload["jti"]) > 0 + + def test_jti_is_unique_per_token(self, test_client: TestClient) -> None: # noqa: ARG002 + from api.api import _create_access_token, _decode_access_token + + t1, _ = _create_access_token(TEST_USER_ID, TEST_USER_EMAIL) + t2, _ = _create_access_token(TEST_USER_ID, TEST_USER_EMAIL) + assert _decode_access_token(t1)["jti"] != _decode_access_token(t2)["jti"] + + +class TestSecurityHeaders: + """Tests for security headers middleware.""" + + def test_x_content_type_options_nosniff(self, test_client: TestClient) -> None: + response = test_client.get("/health") + assert response.headers.get("x-content-type-options") == "nosniff" + + def test_x_frame_options_deny(self, test_client: TestClient) -> None: + response = test_client.get("/health") + assert response.headers.get("x-frame-options") == "DENY" + + def test_referrer_policy(self, test_client: TestClient) -> None: + response = test_client.get("/health") + assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin" + + def test_permissions_policy(self, test_client: TestClient) -> None: + response = test_client.get("/health") + assert "geolocation=()" in response.headers.get("permissions-policy", "") + + +class TestBlindRegistration: + """Tests for L1: blind registration (no user enumeration).""" + + def test_duplicate_email_returns_201_not_409(self, test_client: TestClient, mock_cur: AsyncMock) -> None: + mock_cur.execute.side_effect = Exception("unique constraint violation") + response = test_client.post("/api/auth/register", json={"email": "dup@example.com", "password": "Password123!"}) + assert response.status_code == 201 + + def test_duplicate_and_success_return_same_body(self, test_client: TestClient, mock_cur: AsyncMock) -> None: + from datetime import UTC, datetime + + # Success case + mock_cur.execute.side_effect = None + mock_cur.fetchone.return_value = {"id": TEST_USER_ID, "email": TEST_USER_EMAIL, "is_active": True, "created_at": datetime.now(UTC)} + r1 = test_client.post("/api/auth/register", json={"email": "a@b.com", "password": "Password123!"}) + + # Duplicate case + mock_cur.execute.side_effect = Exception("duplicate key value violates unique constraint") + r2 = test_client.post("/api/auth/register", json={"email": "a@b.com", "password": "Password123!"}) + + assert r1.status_code == r2.status_code == 201 + assert r1.json() == r2.json() + + def test_register_response_no_user_details(self, test_client: TestClient, mock_cur: AsyncMock) -> None: + from datetime import UTC, datetime + + mock_cur.fetchone.return_value = {"id": TEST_USER_ID, "email": TEST_USER_EMAIL, "is_active": True, "created_at": datetime.now(UTC)} + response = test_client.post("/api/auth/register", json={"email": TEST_USER_EMAIL, "password": "Password123!"}) + data = response.json() + assert "id" not in data + assert "hashed_password" not in data diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 00000000..6bf70f3c --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,97 @@ +"""Tests for api/auth.py — shared JWT and OAuth encryption utilities.""" + + +class TestB64UrlEncode: + """Tests for b64url_encode.""" + + def test_encode_no_padding(self) -> None: + from api.auth import b64url_encode + + result = b64url_encode(b"test") + assert "=" not in result + + def test_encode_urlsafe_chars(self) -> None: + from api.auth import b64url_encode + + result = b64url_encode(bytes(range(256))) + assert "+" not in result + assert "/" not in result + + def test_encode_decode_roundtrip(self) -> None: + from api.auth import b64url_decode, b64url_encode + + data = b"hello world \x00\xff" + assert b64url_decode(b64url_encode(data)) == data + + +class TestEncryptOauthToken: + """Tests for encrypt_oauth_token.""" + + def test_encrypt_returns_string(self) -> None: + from cryptography.fernet import Fernet + + from api.auth import encrypt_oauth_token + + key = Fernet.generate_key().decode("ascii") + result = encrypt_oauth_token("my-token", key) + assert isinstance(result, str) + assert result != "my-token" + + def test_encrypt_roundtrip(self) -> None: + from cryptography.fernet import Fernet + + from api.auth import decrypt_oauth_token, encrypt_oauth_token + + key = Fernet.generate_key().decode("ascii") + encrypted = encrypt_oauth_token("secret-token", key) + assert decrypt_oauth_token(encrypted, key) == "secret-token" + + def test_encrypt_different_each_time(self) -> None: + """Fernet uses a random IV so each encryption is unique.""" + from cryptography.fernet import Fernet + + from api.auth import encrypt_oauth_token + + key = Fernet.generate_key().decode("ascii") + assert encrypt_oauth_token("tok", key) != encrypt_oauth_token("tok", key) + + +class TestDecryptOauthToken: + """Tests for decrypt_oauth_token.""" + + def test_no_key_returns_plaintext(self) -> None: + from api.auth import decrypt_oauth_token + + assert decrypt_oauth_token("plaintext-token", None) == "plaintext-token" + + def test_decrypt_valid_token(self) -> None: + from cryptography.fernet import Fernet + + from api.auth import decrypt_oauth_token, encrypt_oauth_token + + key = Fernet.generate_key().decode("ascii") + encrypted = encrypt_oauth_token("my-secret", key) + assert decrypt_oauth_token(encrypted, key) == "my-secret" + + def test_invalid_token_falls_back_to_plaintext(self) -> None: + """InvalidToken exception → fallback to returning the token as-is (migration).""" + from cryptography.fernet import Fernet + + from api.auth import decrypt_oauth_token + + key = Fernet.generate_key().decode("ascii") + # "not-encrypted" is not valid Fernet ciphertext + result = decrypt_oauth_token("not-encrypted", key) + assert result == "not-encrypted" + + def test_wrong_key_falls_back_to_plaintext(self) -> None: + from cryptography.fernet import Fernet + + from api.auth import decrypt_oauth_token, encrypt_oauth_token + + key1 = Fernet.generate_key().decode("ascii") + key2 = Fernet.generate_key().decode("ascii") + encrypted = encrypt_oauth_token("secret", key1) + # Wrong key → fallback to plaintext migration path + result = decrypt_oauth_token(encrypted, key2) + assert result == encrypted # returns ciphertext unchanged diff --git a/tests/api/test_discogs_service.py b/tests/api/test_discogs_service.py index fc384f6c..ca90818f 100644 --- a/tests/api/test_discogs_service.py +++ b/tests/api/test_discogs_service.py @@ -282,3 +282,43 @@ async def test_non_200_raises_error(self) -> None: access_token_secret="accsec", # noqa: S106 user_agent="TestAgent/1.0", ) + + +class TestErrorMessageRedaction: + """Tests that error messages do not expose response body (info disclosure fix).""" + + @pytest.mark.asyncio + async def test_request_oauth_token_error_no_body_in_message(self) -> None: + from unittest.mock import patch + + from api.services.discogs import DiscogsOAuthError, request_oauth_token + + mock_client, _ = _make_mock_httpx_client(401, "SECRET_INTERNAL_DATA") + with patch("api.services.discogs.httpx.AsyncClient", return_value=mock_client), pytest.raises(DiscogsOAuthError) as exc_info: + await request_oauth_token("ckey", "csecret", "Agent/1.0") + assert "SECRET_INTERNAL_DATA" not in str(exc_info.value) + assert "HTTP 401" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_exchange_oauth_verifier_error_no_body_in_message(self) -> None: + from unittest.mock import patch + + from api.services.discogs import DiscogsOAuthError, exchange_oauth_verifier + + mock_client, _ = _make_mock_httpx_client(403, "SENSITIVE_ERROR_BODY") + with patch("api.services.discogs.httpx.AsyncClient", return_value=mock_client), pytest.raises(DiscogsOAuthError) as exc_info: + await exchange_oauth_verifier("ckey", "csecret", "tok", "sec", "verif", "Agent/1.0") + assert "SENSITIVE_ERROR_BODY" not in str(exc_info.value) + assert "HTTP 403" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_fetch_discogs_identity_error_no_body_in_message(self) -> None: + from unittest.mock import patch + + from api.services.discogs import DiscogsOAuthError, fetch_discogs_identity + + mock_client, _ = _make_mock_httpx_client(500, "INTERNAL_SERVER_DETAILS") + with patch("api.services.discogs.httpx.AsyncClient", return_value=mock_client), pytest.raises(DiscogsOAuthError) as exc_info: + await fetch_discogs_identity("ckey", "csecret", "tok", "sec", "Agent/1.0") + assert "INTERNAL_SERVER_DETAILS" not in str(exc_info.value) + assert "HTTP 500" in str(exc_info.value) diff --git a/tests/api/test_explore.py b/tests/api/test_explore.py index ea2731ed..1fa62f50 100644 --- a/tests/api/test_explore.py +++ b/tests/api/test_explore.py @@ -231,58 +231,70 @@ class TestJWT: def test_b64url_decode_no_padding(self) -> None: import base64 - from api.routers.explore import _b64url_decode + from api.auth import b64url_decode data = b'{"sub":"user-1"}' encoded = base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - assert _b64url_decode(encoded) == data + assert b64url_decode(encoded) == data def test_b64url_decode_with_padding_needed(self) -> None: # 1-byte payload needs 3 padding chars import base64 - from api.routers.explore import _b64url_decode + from api.auth import b64url_decode data = b"x" encoded = base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - assert _b64url_decode(encoded) == data + assert b64url_decode(encoded) == data def test_verify_jwt_valid(self) -> None: - from api.routers.explore import _verify_jwt + from api.auth import decode_token from tests.api.conftest import make_test_jwt token = make_test_jwt() - payload = _verify_jwt(token, "test-jwt-secret-for-unit-tests") + payload = decode_token(token, "test-jwt-secret-for-unit-tests") assert payload is not None assert "sub" in payload def test_verify_jwt_wrong_secret(self) -> None: - from api.routers.explore import _verify_jwt + import pytest + + from api.auth import decode_token from tests.api.conftest import make_test_jwt token = make_test_jwt() - assert _verify_jwt(token, "wrong-secret") is None + with pytest.raises(ValueError): + decode_token(token, "wrong-secret") def test_verify_jwt_invalid_format(self) -> None: - from api.routers.explore import _verify_jwt + import pytest + + from api.auth import decode_token - assert _verify_jwt("not.a.valid.jwt.token", "secret") is None - assert _verify_jwt("onlytwoparts.here", "secret") is None + with pytest.raises(ValueError): + decode_token("not.a.valid.jwt.token", "secret") + with pytest.raises(ValueError): + decode_token("onlytwoparts.here", "secret") def test_verify_jwt_expired(self) -> None: - from api.routers.explore import _verify_jwt + import pytest + + from api.auth import decode_token from tests.api.conftest import make_test_jwt token = make_test_jwt(exp=1) # expired in 1970 - assert _verify_jwt(token, "test-jwt-secret-for-unit-tests") is None + with pytest.raises(ValueError, match="expired"): + decode_token(token, "test-jwt-secret-for-unit-tests") def test_verify_jwt_invalid_json_body(self) -> None: - """Cover the except Exception branch when body decodes to non-JSON.""" + """Cover ValueError when body is not valid JSON.""" import base64 as _b64 import hashlib import hmac as _hmac - from api.routers.explore import _verify_jwt + import pytest + + from api.auth import decode_token secret = "test-secret" header_b64 = _b64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode() @@ -290,7 +302,8 @@ def test_verify_jwt_invalid_json_body(self) -> None: body_b64 = _b64.urlsafe_b64encode(b"not-valid-json-{{{{").rstrip(b"=").decode() signing_input = f"{header_b64}.{body_b64}".encode("ascii") sig = _b64.urlsafe_b64encode(_hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()).rstrip(b"=").decode() - assert _verify_jwt(f"{header_b64}.{body_b64}.{sig}", secret) is None + with pytest.raises(Exception): # noqa: B017 + decode_token(f"{header_b64}.{body_b64}.{sig}", secret) @pytest.mark.asyncio async def test_get_optional_user_no_credentials(self) -> None: @@ -380,3 +393,25 @@ def test_expand_valid_type_invalid_category(self, test_client: TestClient) -> No def test_expand_genre_invalid_category(self, test_client: TestClient) -> None: response = test_client.get("/api/expand?node_id=Rock&type=genre&category=nonexistent") assert response.status_code == 400 + + +class TestGetOptionalUserInvalidToken: + """Tests for _get_optional_user with an invalid token (explore router).""" + + @pytest.mark.asyncio + async def test_invalid_token_returns_none(self) -> None: + """explore.py:46-47 — bad Bearer token causes ValueError which returns None.""" + from unittest.mock import MagicMock + + import api.routers.explore as explore_module + from api.routers.explore import _get_optional_user + + original = explore_module._jwt_secret + explore_module._jwt_secret = "test-jwt-secret-for-unit-tests" + try: + creds = MagicMock() + creds.credentials = "not.a.valid.jwt" + result = await _get_optional_user(creds) + assert result is None + finally: + explore_module._jwt_secret = original diff --git a/tests/api/test_snapshot.py b/tests/api/test_snapshot.py index 0242a99a..c41899e7 100644 --- a/tests/api/test_snapshot.py +++ b/tests/api/test_snapshot.py @@ -6,24 +6,24 @@ class TestSaveSnapshot: """Tests for POST /api/snapshot.""" - def test_save_snapshot_success(self, test_client: TestClient) -> None: + def test_save_snapshot_success(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: body = { "nodes": [{"id": "1", "type": "artist"}, {"id": "2", "type": "genre"}], "center": {"id": "1", "type": "artist"}, } - response = test_client.post("/api/snapshot", json=body) + response = test_client.post("/api/snapshot", json=body, headers=auth_headers) assert response.status_code == 201 data = response.json() assert "token" in data assert "url" in data assert "expires_at" in data - def test_save_snapshot_empty_nodes_422(self, test_client: TestClient) -> None: + def test_save_snapshot_empty_nodes_422(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: body = {"nodes": [], "center": {"id": "1", "type": "artist"}} - response = test_client.post("/api/snapshot", json=body) + response = test_client.post("/api/snapshot", json=body, headers=auth_headers) assert response.status_code == 422 - def test_save_snapshot_too_many_nodes(self, test_client: TestClient) -> None: + def test_save_snapshot_too_many_nodes(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: from unittest.mock import PropertyMock, patch body = { @@ -36,25 +36,25 @@ def test_save_snapshot_too_many_nodes(self, test_client: TestClient) -> None: new_callable=PropertyMock, return_value=2, ): - response = test_client.post("/api/snapshot", json=body) + response = test_client.post("/api/snapshot", json=body, headers=auth_headers) assert response.status_code == 422 assert "Too many nodes" in response.json()["error"] - def test_save_snapshot_missing_fields(self, test_client: TestClient) -> None: - response = test_client.post("/api/snapshot", json={"nodes": [{"id": "1", "type": "artist"}]}) + def test_save_snapshot_missing_fields(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + response = test_client.post("/api/snapshot", json={"nodes": [{"id": "1", "type": "artist"}]}, headers=auth_headers) assert response.status_code == 422 class TestRestoreSnapshot: """Tests for GET /api/snapshot/{token}.""" - def test_restore_snapshot_success(self, test_client: TestClient) -> None: + def test_restore_snapshot_success(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: # First save a snapshot body = { "nodes": [{"id": "1", "type": "artist"}], "center": {"id": "1", "type": "artist"}, } - save_response = test_client.post("/api/snapshot", json=body) + save_response = test_client.post("/api/snapshot", json=body, headers=auth_headers) assert save_response.status_code == 201 token = save_response.json()["token"] @@ -92,3 +92,30 @@ def test_restore_snapshot_expired(self, test_client: TestClient) -> None: assert response.status_code == 404 finally: store._store.pop(token, None) + + +class TestSnapshotAuth: + """Tests for _get_current_user in snapshot router.""" + + def test_no_jwt_secret_returns_503(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + """snapshot.py:30 — 503 when _jwt_secret is None.""" + import api.routers.snapshot as snap_module + + original = snap_module._jwt_secret + snap_module._jwt_secret = None + try: + body = {"nodes": [{"id": "1", "type": "artist"}], "center": {"id": "1", "type": "artist"}} + response = test_client.post("/api/snapshot", json=body, headers=auth_headers) + assert response.status_code == 503 + finally: + snap_module._jwt_secret = original + + def test_invalid_token_returns_401(self, test_client: TestClient) -> None: + """snapshot.py:33-34 — 401 on bad token.""" + body = {"nodes": [{"id": "1", "type": "artist"}], "center": {"id": "1", "type": "artist"}} + response = test_client.post( + "/api/snapshot", + json=body, + headers={"Authorization": "Bearer not.a.valid.jwt"}, + ) + assert response.status_code == 401 diff --git a/tests/api/test_sync.py b/tests/api/test_sync.py index afde459a..2766089d 100644 --- a/tests/api/test_sync.py +++ b/tests/api/test_sync.py @@ -306,3 +306,77 @@ async def override_no_sub() -> dict[str, str]: assert response.status_code == 401 finally: del app.dependency_overrides[_get_current_user] + + +class TestSyncRedisCooldown: + """Tests for per-user Redis cooldown in trigger_sync.""" + + def test_in_cooldown_returns_429(self, test_client: TestClient, mock_redis: AsyncMock, auth_headers: dict[str, str]) -> None: + mock_redis.get.return_value = "1" # cooldown active + response = test_client.post("/api/sync", headers=auth_headers) + assert response.status_code == 429 + data = response.json() + assert data["status"] == "cooldown" + + def test_cooldown_key_uses_user_id(self, test_client: TestClient, mock_redis: AsyncMock, auth_headers: dict[str, str]) -> None: + mock_redis.get.return_value = "1" + test_client.post("/api/sync", headers=auth_headers) + mock_redis.get.assert_awaited_once() + call_key = mock_redis.get.call_args[0][0] + assert call_key == f"sync:cooldown:{TEST_USER_ID}" + + def test_sets_cooldown_after_trigger( + self, test_client: TestClient, mock_redis: AsyncMock, mock_cur: AsyncMock, auth_headers: dict[str, str] + ) -> None: + import asyncio + from unittest.mock import MagicMock + + mock_redis.get.return_value = None # not in cooldown + mock_cur.fetchone.return_value = {"id": "new-sync-id"} + with patch("api.routers.sync.asyncio.create_task") as mock_task: + mock_task.return_value = MagicMock(spec=asyncio.Task) + mock_task.return_value.done.return_value = False + test_client.post("/api/sync", headers=auth_headers) + mock_redis.setex.assert_awaited_once() + setex_args = mock_redis.setex.call_args[0] + assert setex_args[0] == f"sync:cooldown:{TEST_USER_ID}" + assert setex_args[1] == 600 + + def test_redis_none_skips_cooldown(self, test_client: TestClient, mock_cur: AsyncMock, auth_headers: dict[str, str]) -> None: + import asyncio + from unittest.mock import MagicMock + + import api.routers.sync as sync_module + + original = sync_module._redis + sync_module._redis = None + mock_cur.fetchone.return_value = {"id": "sync-id"} + try: + with patch("api.routers.sync.asyncio.create_task") as mock_task: + mock_task.return_value = MagicMock(spec=asyncio.Task) + mock_task.return_value.done.return_value = False + response = test_client.post("/api/sync", headers=auth_headers) + assert response.status_code == 202 + finally: + sync_module._redis = original + + def test_trigger_sync_passes_encryption_key(self, test_client: TestClient, mock_cur: AsyncMock, auth_headers: dict[str, str]) -> None: + import asyncio + from unittest.mock import MagicMock + + import api.routers.sync as sync_module + + mock_cur.fetchone.return_value = {"id": "sync-id"} + with patch("api.routers.sync.asyncio.create_task") as mock_task: + mock_task.return_value = MagicMock(spec=asyncio.Task) + mock_task.return_value.done.return_value = False + test_client.post("/api/sync", headers=auth_headers) + + create_task_call = mock_task.call_args + # The coroutine is run_full_sync(...) — check kwargs contain oauth_encryption_key + coroutine = create_task_call[0][0] + assert coroutine is not None + coroutine.close() # clean up unawaited coroutine + + # Verify getattr used on config with oauth_encryption_key + assert hasattr(sync_module._config, "jwt_secret_key") diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 40ff243d..7c2d2c5b 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -121,50 +121,59 @@ def test_status_empty_ids(self, test_client: TestClient) -> None: class TestB64UrlDecode: - """Tests for api.routers.user._b64url_decode.""" + """Tests for api.auth.b64url_decode.""" def test_decode_with_padding_needed(self) -> None: - """Line 42: padding branch executes when length % 4 != 0.""" - from api.routers.user import _b64url_decode + """Padding branch executes when length % 4 != 0.""" + from api.auth import b64url_decode # "YQ" decodes to b"a" but needs 2 padding chars ("YQ==") - result = _b64url_decode("YQ") + result = b64url_decode("YQ") assert result == b"a" def test_decode_aligned_no_padding(self) -> None: """No padding when length already divisible by 4.""" - from api.routers.user import _b64url_decode + from api.auth import b64url_decode # "AAAA" is 4 chars, already aligned - result = _b64url_decode("AAAA") + result = b64url_decode("AAAA") assert result == b"\x00\x00\x00" class TestVerifyJwt: - """Tests for api.routers.user._verify_jwt.""" + """Tests for api.auth.decode_token.""" - def test_wrong_part_count_returns_none(self) -> None: - """Line 49: returns None when token doesn't have 3 parts.""" - from api.routers.user import _verify_jwt + def test_wrong_part_count_raises(self) -> None: + """Raises ValueError when token doesn't have 3 parts.""" + import pytest - assert _verify_jwt("only.two", "secret") is None - assert _verify_jwt("a.b.c.d", "secret") is None + from api.auth import decode_token - def test_bad_signature_returns_none(self) -> None: - """Line 54: returns None when signature doesn't match.""" - from api.routers.user import _verify_jwt + with pytest.raises(ValueError): + decode_token("only.two", "secret") + with pytest.raises(ValueError): + decode_token("a.b.c.d", "secret") + + def test_bad_signature_raises(self) -> None: + """Raises ValueError when signature doesn't match.""" + import pytest + + from api.auth import decode_token from tests.api.conftest import TEST_JWT_SECRET, make_test_jwt token = make_test_jwt(secret="wrong-secret") # noqa: S106 - assert _verify_jwt(token, TEST_JWT_SECRET) is None + with pytest.raises(ValueError): + decode_token(token, TEST_JWT_SECRET) - def test_invalid_json_payload_returns_none(self) -> None: - """Lines 57-58: returns None when body decodes to non-JSON bytes.""" + def test_invalid_json_payload_raises(self) -> None: + """Raises ValueError when body decodes to non-JSON bytes.""" import base64 import hashlib import hmac - from api.routers.user import _verify_jwt + import pytest + + from api.auth import decode_token def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") @@ -176,24 +185,27 @@ def b64url(data: bytes) -> str: sig = b64url(hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()) token = f"{header}.{body}.{sig}" - assert _verify_jwt(token, secret) is None + with pytest.raises(ValueError): + decode_token(token, secret) - def test_expired_token_returns_none(self) -> None: - """Line 61: returns None when token is expired.""" - from api.routers.user import _verify_jwt + def test_expired_token_raises(self) -> None: + """Raises ValueError when token is expired.""" + import pytest + + from api.auth import decode_token from tests.api.conftest import TEST_JWT_SECRET, make_test_jwt expired_token = make_test_jwt(exp=1) # epoch 1970 - assert _verify_jwt(expired_token, TEST_JWT_SECRET) is None + with pytest.raises(ValueError): + decode_token(expired_token, TEST_JWT_SECRET) def test_valid_token_returns_payload(self) -> None: """Happy path: returns the payload dict for a valid token.""" - from api.routers.user import _verify_jwt + from api.auth import decode_token from tests.api.conftest import TEST_JWT_SECRET, TEST_USER_ID, make_test_jwt token = make_test_jwt() - result = _verify_jwt(token, TEST_JWT_SECRET) - assert result is not None + result = decode_token(token, TEST_JWT_SECRET) assert result["sub"] == TEST_USER_ID @@ -265,3 +277,42 @@ def test_collection_stats_no_driver_503(self, test_client: TestClient, auth_head assert response.status_code == 503 finally: user_module._neo4j_driver = original + + +class TestReleaseStatusIdsLimit: + """Tests for GET /api/user/status — 100-ID limit.""" + + def test_over_100_ids_returns_422(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + ids = ",".join(str(i) for i in range(101)) + response = test_client.get(f"/api/user/status?ids={ids}", headers=auth_headers) + assert response.status_code == 422 + assert "Too many IDs" in response.json()["error"] + + def test_exactly_100_ids_allowed(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + from unittest.mock import AsyncMock, patch + + ids = ",".join(str(i) for i in range(100)) + with patch("api.routers.user.check_releases_user_status", new=AsyncMock(return_value={})): + response = test_client.get(f"/api/user/status?ids={ids}", headers=auth_headers) + assert response.status_code == 200 + + def test_error_message_format(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: + ids = ",".join(str(i) for i in range(101)) + data = test_client.get(f"/api/user/status?ids={ids}", headers=auth_headers).json() + assert data["error"] == "Too many IDs: maximum is 100" + + +class TestGetOptionalUserInvalidToken: + """Tests for _get_optional_user with an invalid token (user router).""" + + def test_invalid_token_returns_false_flags(self, test_client: TestClient) -> None: + """user.py:42-43 — bad token on optional-auth /status falls back to all-False.""" + response = test_client.get( + "/api/user/status?ids=1,2", + headers={"Authorization": "Bearer not.a.valid.jwt"}, + ) + assert response.status_code == 200 + data = response.json() + for _rid, flags in data["status"].items(): + assert flags["in_collection"] is False + assert flags["in_wantlist"] is False diff --git a/tests/curator/conftest.py b/tests/curator/conftest.py index 37195a5f..6c25af1a 100644 --- a/tests/curator/conftest.py +++ b/tests/curator/conftest.py @@ -15,7 +15,6 @@ os.environ.setdefault("NEO4J_ADDRESS", "bolt://localhost:7687") os.environ.setdefault("NEO4J_USERNAME", "neo4j") os.environ.setdefault("NEO4J_PASSWORD", "testpassword") -os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-for-unit-tests") from collections.abc import AsyncGenerator, Generator from contextlib import asynccontextmanager @@ -114,7 +113,6 @@ def test_curator_config() -> CuratorConfig: neo4j_address="bolt://localhost:7687", neo4j_username="neo4j", neo4j_password="testpassword", # noqa: S106 - jwt_secret_key=TEST_JWT_SECRET, ) diff --git a/tests/explore/conftest.py b/tests/explore/conftest.py index 7e174214..4064d043 100644 --- a/tests/explore/conftest.py +++ b/tests/explore/conftest.py @@ -101,8 +101,10 @@ async def health_check() -> dict[str, Any]: test_app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") # Wire the mock driver into both api routers - explore_router_module.configure(mock_neo4j_driver, os.environ.get("JWT_SECRET_KEY")) - user_router_module.configure(mock_neo4j_driver, os.environ.get("JWT_SECRET_KEY")) + jwt_secret = os.environ.get("JWT_SECRET_KEY") + explore_router_module.configure(mock_neo4j_driver, jwt_secret) + user_router_module.configure(mock_neo4j_driver, jwt_secret) + snapshot_router_module.configure(jwt_secret=jwt_secret) # Clear autocomplete cache between tests explore_router_module._autocomplete_cache.clear() @@ -113,6 +115,28 @@ async def health_check() -> dict[str, Any]: # Restore router state (set driver back to None so tests are isolated) explore_router_module.configure(None, None) user_router_module.configure(None, None) + snapshot_router_module.configure(jwt_secret=None) + + +@pytest.fixture +def auth_headers() -> dict[str, str]: + """Authorization headers with a valid bearer token for explore tests.""" + import base64 + import hashlib + import hmac + import json + + secret = os.environ.get("JWT_SECRET_KEY", "test-jwt-secret-for-unit-tests") + + def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + body = b64url(json.dumps({"sub": "00000000-0000-0000-0000-000000000001", "exp": 9_999_999_999}, separators=(",", ":")).encode()) + signing_input = f"{header}.{body}".encode("ascii") + sig = b64url(hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()) + token = f"{header}.{body}.{sig}" + return {"Authorization": f"Bearer {token}"} @pytest.fixture diff --git a/tests/explore/test_explore_api.py b/tests/explore/test_explore_api.py index 931c48a6..675583b3 100644 --- a/tests/explore/test_explore_api.py +++ b/tests/explore/test_explore_api.py @@ -755,77 +755,76 @@ async def test_lifespan_startup_and_shutdown(self) -> None: class TestJwtHelpers: - """Test the JWT helper functions _b64url_decode and _verify_jwt.""" + """Test the JWT helper functions b64url_decode and decode_token.""" def test_b64url_decode_with_padding(self) -> None: - """Test _b64url_decode adds padding when length % 4 != 0 (covers lines 61-62).""" - from api.routers.explore import _b64url_decode + """Test b64url_decode adds padding when length % 4 != 0.""" + from api.auth import b64url_decode # "dGVzdA" is 6 chars (6 % 4 == 2), so padding = 2 is added # This is base64url encoding of b"test" - result = _b64url_decode("dGVzdA") + result = b64url_decode("dGVzdA") assert result == b"test" def test_b64url_decode_no_padding_needed(self) -> None: - """Test _b64url_decode when length % 4 == 0 (no padding added).""" - from api.routers.explore import _b64url_decode + """Test b64url_decode when length % 4 == 0 (no padding added).""" + from api.auth import b64url_decode # "YWJj" is 4 chars (4 % 4 == 0), no padding added # This is base64url encoding of b"abc" - result = _b64url_decode("YWJj") + result = b64url_decode("YWJj") assert result == b"abc" def test_verify_jwt_valid_token(self) -> None: - """Test _verify_jwt returns payload for a valid token.""" - from api.routers.explore import _verify_jwt + """Test decode_token returns payload for a valid token.""" + from api.auth import decode_token token = _make_explore_jwt() - payload = _verify_jwt(token, TEST_EXPLORE_JWT_SECRET) - assert payload is not None + payload = decode_token(token, TEST_EXPLORE_JWT_SECRET) assert payload["sub"] == TEST_EXPLORE_USER_ID def test_verify_jwt_malformed_token_too_few_parts(self) -> None: - """Test _verify_jwt returns None when token has wrong number of parts.""" - from api.routers.explore import _verify_jwt + """Test decode_token raises ValueError when token has wrong number of parts.""" + from api.auth import decode_token - result = _verify_jwt("only.two", TEST_EXPLORE_JWT_SECRET) - assert result is None + with pytest.raises(ValueError): + decode_token("only.two", TEST_EXPLORE_JWT_SECRET) def test_verify_jwt_wrong_signature(self) -> None: - """Test _verify_jwt returns None when signature is invalid.""" - from api.routers.explore import _verify_jwt + """Test decode_token raises ValueError when signature is invalid.""" + from api.auth import decode_token token = _make_explore_jwt() parts = token.split(".") # Corrupt the last character of the signature bad_sig = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B") bad_token = f"{parts[0]}.{parts[1]}.{bad_sig}" - result = _verify_jwt(bad_token, TEST_EXPLORE_JWT_SECRET) - assert result is None + with pytest.raises(ValueError): + decode_token(bad_token, TEST_EXPLORE_JWT_SECRET) def test_verify_jwt_invalid_body_not_json(self) -> None: - """Test _verify_jwt returns None when body cannot be JSON decoded.""" - from api.routers.explore import _verify_jwt + """Test decode_token raises ValueError when body cannot be JSON decoded.""" + from api.auth import decode_token token = _make_invalid_body_jwt() - result = _verify_jwt(token, TEST_EXPLORE_JWT_SECRET) - assert result is None + with pytest.raises(ValueError): + decode_token(token, TEST_EXPLORE_JWT_SECRET) def test_verify_jwt_expired_token(self) -> None: - """Test _verify_jwt returns None for an expired token.""" - from api.routers.explore import _verify_jwt + """Test decode_token raises ValueError for an expired token.""" + from api.auth import decode_token expired_token = _make_explore_jwt(exp=int(time.time()) - 100) - result = _verify_jwt(expired_token, TEST_EXPLORE_JWT_SECRET) - assert result is None + with pytest.raises(ValueError): + decode_token(expired_token, TEST_EXPLORE_JWT_SECRET) def test_verify_jwt_wrong_secret(self) -> None: - """Test _verify_jwt returns None when wrong secret is used.""" - from api.routers.explore import _verify_jwt + """Test decode_token raises ValueError when wrong secret is used.""" + from api.auth import decode_token token = _make_explore_jwt(secret=TEST_EXPLORE_JWT_SECRET) - result = _verify_jwt(token, "wrong-secret") - assert result is None + with pytest.raises(ValueError): + decode_token(token, "wrong-secret") class TestRequireUserDependency: diff --git a/tests/explore/test_snapshot.py b/tests/explore/test_snapshot.py index c0e2b034..7c138420 100644 --- a/tests/explore/test_snapshot.py +++ b/tests/explore/test_snapshot.py @@ -99,7 +99,7 @@ def test_tokens_are_unique(self) -> None: class TestSaveSnapshotEndpoint: """Tests for POST /api/snapshot.""" - def test_save_snapshot_success(self, test_client: TestClient) -> None: + def test_save_snapshot_success(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: payload = { "nodes": [ {"id": "1", "type": "artist"}, @@ -107,7 +107,7 @@ def test_save_snapshot_success(self, test_client: TestClient) -> None: ], "center": {"id": "1", "type": "artist"}, } - response = test_client.post("/api/snapshot", json=payload) + response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert response.status_code == 201 data = response.json() assert "token" in data @@ -115,7 +115,7 @@ def test_save_snapshot_success(self, test_client: TestClient) -> None: assert "expires_at" in data assert data["url"] == f"/snapshot/{data['token']}" - def test_save_snapshot_exceeds_max_nodes(self, test_client: TestClient) -> None: + def test_save_snapshot_exceeds_max_nodes(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: import api.routers.snapshot as snapshot_module original_store = snapshot_module._snapshot_store @@ -131,35 +131,35 @@ def test_save_snapshot_exceeds_max_nodes(self, test_client: TestClient) -> None: ], "center": {"id": "1", "type": "artist"}, } - response = test_client.post("/api/snapshot", json=payload) + response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert response.status_code == 422 data = response.json() assert "error" in data snapshot_module._snapshot_store = original_store - def test_save_snapshot_empty_nodes_rejected(self, test_client: TestClient) -> None: + def test_save_snapshot_empty_nodes_rejected(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: payload = { "nodes": [], "center": {"id": "1", "type": "artist"}, } - response = test_client.post("/api/snapshot", json=payload) + response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert response.status_code == 422 - def test_save_snapshot_missing_center(self, test_client: TestClient) -> None: + def test_save_snapshot_missing_center(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: payload = { "nodes": [{"id": "1", "type": "artist"}], } - response = test_client.post("/api/snapshot", json=payload) + response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert response.status_code == 422 - def test_save_snapshot_returns_valid_expiry(self, test_client: TestClient) -> None: + def test_save_snapshot_returns_valid_expiry(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: payload = { "nodes": [{"id": "1", "type": "artist"}], "center": {"id": "1", "type": "artist"}, } before = datetime.now(UTC) - response = test_client.post("/api/snapshot", json=payload) + response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert response.status_code == 201 data = response.json() expires_at = datetime.fromisoformat(data["expires_at"]) @@ -170,7 +170,7 @@ def test_save_snapshot_returns_valid_expiry(self, test_client: TestClient) -> No class TestRestoreSnapshotEndpoint: """Tests for GET /api/snapshot/{token}.""" - def test_restore_valid_snapshot(self, test_client: TestClient) -> None: + def test_restore_valid_snapshot(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: # First save a snapshot payload: dict[str, Any] = { "nodes": [ @@ -180,7 +180,7 @@ def test_restore_valid_snapshot(self, test_client: TestClient) -> None: ], "center": {"id": "1", "type": "artist"}, } - save_response = test_client.post("/api/snapshot", json=payload) + save_response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert save_response.status_code == 201 token = save_response.json()["token"] @@ -201,7 +201,7 @@ def test_restore_unknown_token_returns_404(self, test_client: TestClient) -> Non data = response.json() assert "error" in data - def test_restore_expired_token_returns_404(self, test_client: TestClient) -> None: + def test_restore_expired_token_returns_404(self, test_client: TestClient, auth_headers: dict[str, str]) -> None: import api.routers.snapshot as snapshot_module # Save a snapshot @@ -209,7 +209,7 @@ def test_restore_expired_token_returns_404(self, test_client: TestClient) -> Non "nodes": [{"id": "1", "type": "artist"}], "center": {"id": "1", "type": "artist"}, } - save_response = test_client.post("/api/snapshot", json=payload) + save_response = test_client.post("/api/snapshot", json=payload, headers=auth_headers) assert save_response.status_code == 201 token = save_response.json()["token"] diff --git a/tests/explore/test_user_queries.py b/tests/explore/test_user_queries.py index 0daf820e..2fc41ca1 100644 --- a/tests/explore/test_user_queries.py +++ b/tests/explore/test_user_queries.py @@ -67,7 +67,7 @@ async def test_returns_status_dict(self) -> None: class TestJwtVerification: - """Tests for the JWT verification helpers in explore.explore.""" + """Tests for api.auth.decode_token.""" def test_valid_token_returns_payload(self) -> None: import base64 @@ -75,7 +75,7 @@ def test_valid_token_returns_payload(self) -> None: import hmac import json - from api.routers.explore import _verify_jwt + from api.auth import decode_token secret = "test-secret" @@ -89,17 +89,18 @@ def b64url(data: bytes) -> str: sig = b64url(hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()) token = f"{header}.{body}.{sig}" - payload = _verify_jwt(token, secret) - assert payload is not None + payload = decode_token(token, secret) assert payload["sub"] == "user-123" - def test_wrong_secret_returns_none(self) -> None: + def test_wrong_secret_raises(self) -> None: import base64 import hashlib import hmac import json - from api.routers.explore import _verify_jwt + import pytest + + from api.auth import decode_token def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") @@ -110,21 +111,28 @@ def b64url(data: bytes) -> str: sig = b64url(hmac.new(b"correct-secret", signing_input, hashlib.sha256).digest()) token = f"{header}.{body}.{sig}" - assert _verify_jwt(token, "wrong-secret") is None + with pytest.raises(ValueError): + decode_token(token, "wrong-secret") + + def test_malformed_token_raises(self) -> None: + import pytest - def test_malformed_token_returns_none(self) -> None: - from api.routers.explore import _verify_jwt + from api.auth import decode_token - assert _verify_jwt("not.a.valid.jwt.parts", "secret") is None - assert _verify_jwt("only.two", "secret") is None + with pytest.raises(ValueError): + decode_token("not.a.valid.jwt.parts", "secret") + with pytest.raises(ValueError): + decode_token("only.two", "secret") - def test_expired_token_returns_none(self) -> None: + def test_expired_token_raises(self) -> None: import base64 import hashlib import hmac import json - from api.routers.explore import _verify_jwt + import pytest + + from api.auth import decode_token secret = "test-secret" @@ -138,34 +146,35 @@ def b64url(data: bytes) -> str: sig = b64url(hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()) token = f"{header}.{body}.{sig}" - assert _verify_jwt(token, secret) is None + with pytest.raises(ValueError): + decode_token(token, secret) class TestB64UrlDecode: - """Tests for explore._b64url_decode.""" + """Tests for api.auth.b64url_decode.""" def test_decode_with_padding_needed(self) -> None: - from api.routers.explore import _b64url_decode + from api.auth import b64url_decode # base64url of b"a" without padding is "YQ" - result = _b64url_decode("YQ") + result = b64url_decode("YQ") assert result == b"a" def test_decode_already_aligned(self) -> None: - from api.routers.explore import _b64url_decode + from api.auth import b64url_decode # "AAAA" decodes to 3 zero bytes - result = _b64url_decode("AAAA") + result = b64url_decode("AAAA") assert result == b"\x00\x00\x00" def test_roundtrip_with_urlsafe_chars(self) -> None: import base64 - from api.routers.explore import _b64url_decode + from api.auth import b64url_decode original = b"hello world!" encoded = base64.urlsafe_b64encode(original).rstrip(b"=").decode("ascii") - assert _b64url_decode(encoded) == original + assert b64url_decode(encoded) == original def _make_driver_with_rows(rows: list[dict[str, Any]], count_record: dict[str, Any] | None = None) -> MagicMock: diff --git a/tests/test_config.py b/tests/test_config.py index 2314fbbc..60e60cee 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -489,22 +489,11 @@ def test_from_env_with_all_required_vars(self, monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv("NEO4J_ADDRESS", "bolt://neo4j:7687") monkeypatch.setenv("NEO4J_USERNAME", "neo4j") monkeypatch.setenv("NEO4J_PASSWORD", "neo4jpass") - monkeypatch.setenv("JWT_SECRET_KEY", "jwtsecret") config = CuratorConfig.from_env() assert config.postgres_address == "pghost:5432" assert config.neo4j_address == "bolt://neo4j:7687" - assert config.jwt_secret_key == "jwtsecret" - - def test_from_env_missing_jwt_secret(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test missing JWT_SECRET_KEY raises ValueError.""" - from common.config import CuratorConfig - - monkeypatch.delenv("JWT_SECRET_KEY", raising=False) - - with pytest.raises(ValueError, match="JWT_SECRET_KEY"): - CuratorConfig.from_env() def test_from_env_missing_neo4j_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test missing Neo4j vars raises ValueError.""" @@ -519,7 +508,6 @@ def test_from_env_custom_user_agent(self, monkeypatch: pytest.MonkeyPatch) -> No """Test custom DISCOGS_USER_AGENT is read.""" from common.config import CuratorConfig - monkeypatch.setenv("JWT_SECRET_KEY", "secret") monkeypatch.setenv("DISCOGS_USER_AGENT", "MyAgent/3.0") config = CuratorConfig.from_env() @@ -531,6 +519,7 @@ def test_from_env_missing_postgres_password(self, monkeypatch: pytest.MonkeyPatc from common.config import CuratorConfig monkeypatch.delenv("POSTGRES_PASSWORD", raising=False) + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) with pytest.raises(ValueError, match="POSTGRES_PASSWORD"): CuratorConfig.from_env() @@ -638,3 +627,204 @@ def test_from_env_missing_required_raises(self, monkeypatch: pytest.MonkeyPatch) with pytest.raises(ValueError, match="JWT_SECRET_KEY"): ApiConfig.from_env() + + +class TestApiConfigNewFields: + """Tests for new ApiConfig fields added in the security hardening.""" + + def test_snapshot_ttl_days_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.delenv("SNAPSHOT_TTL_DAYS", raising=False) + assert ApiConfig.from_env().snapshot_ttl_days == 28 + + def test_snapshot_ttl_days_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("SNAPSHOT_TTL_DAYS", "14") + assert ApiConfig.from_env().snapshot_ttl_days == 14 + + def test_snapshot_ttl_days_invalid_uses_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("SNAPSHOT_TTL_DAYS", "not-a-number") + assert ApiConfig.from_env().snapshot_ttl_days == 28 + + def test_snapshot_max_nodes_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.delenv("SNAPSHOT_MAX_NODES", raising=False) + assert ApiConfig.from_env().snapshot_max_nodes == 100 + + def test_snapshot_max_nodes_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("SNAPSHOT_MAX_NODES", "50") + assert ApiConfig.from_env().snapshot_max_nodes == 50 + + def test_snapshot_max_nodes_invalid_uses_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("SNAPSHOT_MAX_NODES", "bad") + assert ApiConfig.from_env().snapshot_max_nodes == 100 + + def test_oauth_encryption_key_none_when_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.delenv("OAUTH_ENCRYPTION_KEY", raising=False) + assert ApiConfig.from_env().oauth_encryption_key is None + + def test_oauth_encryption_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("OAUTH_ENCRYPTION_KEY", "my-fernet-key") + assert ApiConfig.from_env().oauth_encryption_key == "my-fernet-key" + + def test_jwt_algorithm_non_hs256_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("JWT_ALGORITHM", "RS256") + with pytest.raises(ValueError, match="Unsupported JWT algorithm"): + ApiConfig.from_env() + + def test_cors_origins_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.setenv("CORS_ORIGINS", "https://app.example.com,https://other.example.com") + config = ApiConfig.from_env() + assert config.cors_origins == ["https://app.example.com", "https://other.example.com"] + + def test_cors_origins_none_when_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import ApiConfig + + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + monkeypatch.delenv("CORS_ORIGINS", raising=False) + assert ApiConfig.from_env().cors_origins is None + + +class TestCuratorConfigNoJwtRequired: + """CuratorConfig no longer requires JWT_SECRET_KEY.""" + + def test_curator_config_works_without_jwt_secret(self, monkeypatch: pytest.MonkeyPatch) -> None: + from common.config import CuratorConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "pghost:5432") + monkeypatch.setenv("POSTGRES_USERNAME", "pguser") + monkeypatch.setenv("POSTGRES_PASSWORD", "pgpass") + monkeypatch.setenv("POSTGRES_DATABASE", "mydb") + monkeypatch.setenv("RABBITMQ_URL", "amqp://localhost/") + monkeypatch.setenv("NEO4J_ADDRESS", "bolt://localhost:7687") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "password") + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + # Should not raise + config = CuratorConfig.from_env() + assert not hasattr(config, "jwt_secret_key") or config.jwt_secret_key is None # type: ignore[attr-defined] + + +class TestConfigMissingVars: + """Tests for individual missing-variable branches in ApiConfig and CuratorConfig.""" + + def test_api_config_missing_postgres_password(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:390 — POSTGRES_PASSWORD missing fires the append.""" + from common.config import ApiConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "localhost") + monkeypatch.setenv("POSTGRES_USERNAME", "user") + monkeypatch.delenv("POSTGRES_PASSWORD", raising=False) + monkeypatch.setenv("POSTGRES_DATABASE", "db") + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + with pytest.raises(ValueError, match="POSTGRES_PASSWORD"): + ApiConfig.from_env() + + def test_api_config_missing_postgres_database(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:392 — POSTGRES_DATABASE missing fires the append.""" + from common.config import ApiConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "localhost") + monkeypatch.setenv("POSTGRES_USERNAME", "user") + monkeypatch.setenv("POSTGRES_PASSWORD", "pass") + monkeypatch.delenv("POSTGRES_DATABASE", raising=False) + monkeypatch.setenv("JWT_SECRET_KEY", "secret") + with pytest.raises(ValueError, match="POSTGRES_DATABASE"): + ApiConfig.from_env() + + def test_curator_config_missing_postgres_address(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:480 — POSTGRES_ADDRESS missing.""" + from common.config import CuratorConfig + + monkeypatch.delenv("POSTGRES_ADDRESS", raising=False) + monkeypatch.setenv("POSTGRES_USERNAME", "user") + monkeypatch.setenv("POSTGRES_PASSWORD", "pass") + monkeypatch.setenv("POSTGRES_DATABASE", "db") + monkeypatch.setenv("NEO4J_ADDRESS", "bolt://localhost") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "neo4jpass") + with pytest.raises(ValueError, match="POSTGRES_ADDRESS"): + CuratorConfig.from_env() + + def test_curator_config_missing_postgres_username(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:482 — POSTGRES_USERNAME missing.""" + from common.config import CuratorConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "localhost") + monkeypatch.delenv("POSTGRES_USERNAME", raising=False) + monkeypatch.setenv("POSTGRES_PASSWORD", "pass") + monkeypatch.setenv("POSTGRES_DATABASE", "db") + monkeypatch.setenv("NEO4J_ADDRESS", "bolt://localhost") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "neo4jpass") + with pytest.raises(ValueError, match="POSTGRES_USERNAME"): + CuratorConfig.from_env() + + def test_curator_config_missing_postgres_database(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:486 — POSTGRES_DATABASE missing.""" + from common.config import CuratorConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "localhost") + monkeypatch.setenv("POSTGRES_USERNAME", "user") + monkeypatch.setenv("POSTGRES_PASSWORD", "pass") + monkeypatch.delenv("POSTGRES_DATABASE", raising=False) + monkeypatch.setenv("NEO4J_ADDRESS", "bolt://localhost") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "neo4jpass") + with pytest.raises(ValueError, match="POSTGRES_DATABASE"): + CuratorConfig.from_env() + + def test_curator_config_missing_neo4j_username(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:490 — NEO4J_USERNAME missing.""" + from common.config import CuratorConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "localhost") + monkeypatch.setenv("POSTGRES_USERNAME", "user") + monkeypatch.setenv("POSTGRES_PASSWORD", "pass") + monkeypatch.setenv("POSTGRES_DATABASE", "db") + monkeypatch.setenv("NEO4J_ADDRESS", "bolt://localhost") + monkeypatch.delenv("NEO4J_USERNAME", raising=False) + monkeypatch.setenv("NEO4J_PASSWORD", "neo4jpass") + with pytest.raises(ValueError, match="NEO4J_USERNAME"): + CuratorConfig.from_env() + + def test_curator_config_missing_neo4j_password(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config.py:492 — NEO4J_PASSWORD missing.""" + from common.config import CuratorConfig + + monkeypatch.setenv("POSTGRES_ADDRESS", "localhost") + monkeypatch.setenv("POSTGRES_USERNAME", "user") + monkeypatch.setenv("POSTGRES_PASSWORD", "pass") + monkeypatch.setenv("POSTGRES_DATABASE", "db") + monkeypatch.setenv("NEO4J_ADDRESS", "bolt://localhost") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.delenv("NEO4J_PASSWORD", raising=False) + with pytest.raises(ValueError, match="NEO4J_PASSWORD"): + CuratorConfig.from_env() diff --git a/uv.lock b/uv.lock index e4472df2..5645e678 100644 --- a/uv.lock +++ b/uv.lock @@ -275,12 +275,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "(platform_machine == 'arm64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, +] + [[package]] name = "deflate-dict" version = "1.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2f/05/ca5ea149d91d172b9b9d64547b33a87e9613038deb4c8c7bbaf76e26a4be/deflate_dict-1.2.2.tar.gz", hash = "sha256:a51f72562a2056118c6b0915b4ac46a483cc329b43d13d759307e4c027ea20ea", size = 6946, upload-time = "2024-10-26T16:36:22.057Z" } +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "dict-hash" version = "1.3.7" @@ -311,6 +361,7 @@ dependencies = [ all = [ { name = "bandit", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "black", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "cryptography", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "isort", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -332,6 +383,7 @@ all = [ { name = "redis", extra = ["hiredis"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "requests", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "ruff", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "slowapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "structlog", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "types-psutil", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "types-tqdm", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -339,6 +391,7 @@ all = [ { name = "websockets", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] api = [ + { name = "cryptography", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "neo4j", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -346,6 +399,7 @@ api = [ { name = "psycopg", extra = ["binary"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pydantic", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "redis", extra = ["hiredis"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "slowapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "structlog", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "uvicorn", extra = ["standard"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] @@ -436,6 +490,7 @@ requires-dist = [ { name = "aio-pika", specifier = ">=9.0.0" }, { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=24.0.0" }, + { name = "cryptography", marker = "extra == 'api'", specifier = ">=43.0.0" }, { name = "dict-hash", specifier = ">=1.1.0" }, { name = "discogsography", extras = ["api", "curator", "dashboard", "explore", "graphinator", "schema-init", "tableinator", "dev", "utilities"], marker = "extra == 'all'" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.6" }, @@ -484,6 +539,7 @@ requires-dist = [ { name = "redis", extras = ["hiredis"], marker = "extra == 'api'", specifier = ">=6.2.0" }, { name = "requests", marker = "extra == 'utilities'", specifier = ">=2.31.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "slowapi", marker = "extra == 'api'", specifier = ">=0.1.9" }, { name = "structlog", specifier = ">=24.0.0" }, { name = "structlog", marker = "extra == 'api'", specifier = ">=24.0.0" }, { name = "structlog", marker = "extra == 'curator'", specifier = ">=24.0.0" }, @@ -526,6 +582,7 @@ name = "discogsography-api" version = "0.1.0" source = { editable = "api" } dependencies = [ + { name = "cryptography", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "neo4j", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -533,12 +590,14 @@ dependencies = [ { name = "psycopg", extra = ["binary"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pydantic", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "redis", extra = ["hiredis"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "slowapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "structlog", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "uvicorn", extra = ["standard"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=43.0.0" }, { name = "fastapi", specifier = ">=0.115.6" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "neo4j", specifier = ">=6.1.0" }, @@ -546,6 +605,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "redis", extras = ["hiredis"], specifier = ">=6.2.0" }, + { name = "slowapi", specifier = ">=0.1.9" }, { name = "structlog", specifier = ">=24.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, ] @@ -1057,6 +1117,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "packaging", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "typing-extensions", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "locust" version = "2.43.3" @@ -1878,6 +1952,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "starlette" version = "0.52.1" @@ -2156,6 +2242,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ca/3cf290212855b19af9fcc41b725b5620b32f470d6aad970c2593500817eb/wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139", size = 61150, upload-time = "2026-02-03T02:12:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/33/5b8f89a82a9859ce82da4870c799ad11ce15648b6e1c820fec3e23f4a19f/wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b", size = 61743, upload-time = "2026-02-03T02:11:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2f/60c51304fbdf47ce992d9eefa61fbd2c0e64feee60aaa439baf42ea6f40b/wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98", size = 121341, upload-time = "2026-02-03T02:11:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/ce5256e66dd94e521ad5e753c78185c01b6eddbed3147be541f4d38c0cb7/wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789", size = 122947, upload-time = "2026-02-03T02:11:33.596Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/50ca8854b81b946a11a36fcd6ead32336e6db2c14b6e4a8b092b80741178/wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d", size = 121370, upload-time = "2026-02-03T02:11:09.886Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/d6a7c654e0043319b4cc137a4caaf7aa16b46b51ee8df98d1060254705b7/wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359", size = 120465, upload-time = "2026-02-03T02:11:37.592Z" }, + { url = "https://files.pythonhosted.org/packages/80/b4/fe95beb8946700b3db371f6ce25115217e7075ca063663b8cca2888ba55c/wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20", size = 62969, upload-time = "2026-02-03T02:11:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/477b0bdc784e3299edf69c279697372b8bd4c31d9c6966eae405442899df/wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612", size = 63606, upload-time = "2026-02-03T02:12:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/55/9d0c1269ab76de87715b3b905df54dd25d55bbffd0b98696893eb613469f/wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738", size = 152536, upload-time = "2026-02-03T02:11:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/2004766030462f79ad86efaa62000b5e39b1ff001dcce86650e1625f40ae/wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf", size = 158697, upload-time = "2026-02-03T02:12:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/e1/bb/0a880fa0f35e94ee843df4ee4dd52a699c9263f36881311cfb412c09c3e5/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7", size = 155563, upload-time = "2026-02-03T02:11:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/cd1b7c4846c8678fac359a6eb975dc7ab5bd606030adb22acc8b4a9f53f1/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e", size = 150161, upload-time = "2026-02-03T02:12:33.613Z" }, + { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311, upload-time = "2026-02-03T02:12:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805, upload-time = "2026-02-03T02:11:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308, upload-time = "2026-02-03T02:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688, upload-time = "2026-02-03T02:11:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115, upload-time = "2026-02-03T02:12:39.068Z" }, + { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484, upload-time = "2026-02-03T02:11:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059, upload-time = "2026-02-03T02:12:06.368Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618, upload-time = "2026-02-03T02:12:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544, upload-time = "2026-02-03T02:11:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700, upload-time = "2026-02-03T02:12:30.684Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561, upload-time = "2026-02-03T02:11:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188, upload-time = "2026-02-03T02:11:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, +] + [[package]] name = "wsproto" version = "1.3.2"