diff --git a/data/download_queue.json b/data/download_queue.json new file mode 100644 index 0000000..369f71f --- /dev/null +++ b/data/download_queue.json @@ -0,0 +1,349 @@ +{ + "pending": [ + { + "id": "2f55bb9b-b283-4b6f-89e9-c6792ab7caec", + "serie_id": "workflow-series", + "serie_name": "Workflow Test Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "high", + "added_at": "2025-10-19T18:45:30.539850", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "3c21028d-b693-48a2-ad74-8e5a6cb64df3", + "serie_id": "series-high", + "serie_name": "Series High", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "high", + "added_at": "2025-10-19T18:45:29.960612", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "8eef16de-4f7e-4d15-9d0a-c06f3a518b97", + "serie_id": "test-series-2", + "serie_name": "Another Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "high", + "added_at": "2025-10-19T18:45:29.927823", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "b8f261a5-37f1-436a-a2c0-f6c117336d1c", + "serie_id": "series-normal", + "serie_name": "Series Normal", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:29.962485", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "d1200f69-f8c5-4f72-8036-9a0d977ffc11", + "serie_id": "series-low", + "serie_name": "Series Low", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "low", + "added_at": "2025-10-19T18:45:29.964184", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "ce975176-a09a-4cf0-9c05-848b41a4782e", + "serie_id": "test-series", + "serie_name": "Test Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.164321", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "8dd8d2da-174b-45e3-a65d-874f6f287647", + "serie_id": "series-0", + "serie_name": "Series 0", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.216242", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "e1260703-8b35-45e4-8c92-94a5c73bb55c", + "serie_id": "test-series", + "serie_name": "Test Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.249755", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "49a971a2-9f03-4615-9ceb-4ff59bad091e", + "serie_id": "invalid-series", + "serie_name": "Invalid Series", + "episode": { + "season": 99, + "episode": 99, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.315163", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "968764a6-3c32-45fb-9d25-f8a66d5dd11b", + "serie_id": "test-series", + "serie_name": "Test Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.341633", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "19936ed5-557a-429a-8995-cf4b559ab707", + "serie_id": "series-3", + "serie_name": "Series 3", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.397524", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "be83afe7-7ca4-4c7a-b162-0f0546d727b8", + "serie_id": "series-2", + "serie_name": "Series 2", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.398322", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "a273d44b-a5d0-48b4-87d5-e6ada165593c", + "serie_id": "series-4", + "serie_name": "Series 4", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.398929", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "30498265-d3bf-4a2c-9bc4-912c7ba5eeba", + "serie_id": "series-0", + "serie_name": "Series 0", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.399472", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "aaebb8ea-2360-4dd4-a218-86a244f0258f", + "serie_id": "series-1", + "serie_name": "Series 1", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.400026", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "45a2d1c1-f28f-4cc4-b767-a44e4de49e52", + "serie_id": "persistent-series", + "serie_name": "Persistent Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.463785", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "503d27dd-a691-4451-86f6-0ad191803b64", + "serie_id": "ws-series", + "serie_name": "WebSocket Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.514154", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "445173fb-84ff-404b-b0e4-d70a125b0c9f", + "serie_id": "pause-test", + "serie_name": "Pause Test Series", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-19T18:45:30.570440", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + } + ], + "active": [], + "failed": [], + "timestamp": "2025-10-19T18:45:30.570822" +} \ No newline at end of file diff --git a/src/server/api/anime.py b/src/server/api/anime.py index 441976e..466574d 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -3,7 +3,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel -from src.server.utils.dependencies import get_series_app +from src.server.utils.dependencies import get_series_app, require_auth router = APIRouter(prefix="/api/v1/anime", tags=["anime"]) @@ -22,7 +22,10 @@ class AnimeDetail(BaseModel): @router.get("/", response_model=List[AnimeSummary]) -async def list_anime(series_app=Depends(get_series_app)): +async def list_anime( + _auth: dict = Depends(require_auth), + series_app=Depends(get_series_app) +): """List series with missing episodes using the core SeriesApp.""" try: series = series_app.List.GetMissingEpisode() diff --git a/src/server/api/auth.py b/src/server/api/auth.py index abcbaae..44f29ad 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -1,7 +1,9 @@ +"""Authentication API endpoints for Aniworld.""" from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials +from fastapi import APIRouter, Depends, HTTPException +from fastapi import status as http_status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.server.models.auth import AuthStatus, LoginRequest, LoginResponse, SetupRequest from src.server.services.auth_service import AuthError, LockedOutError, auth_service @@ -11,20 +13,23 @@ from src.server.services.auth_service import AuthError, LockedOutError, auth_ser router = APIRouter(prefix="/api/auth", tags=["auth"]) +# HTTPBearer for optional authentication +optional_bearer = HTTPBearer(auto_error=False) -@router.post("/setup", status_code=status.HTTP_201_CREATED) + +@router.post("/setup", status_code=http_status.HTTP_201_CREATED) def setup_auth(req: SetupRequest): """Initial setup endpoint to configure the master password.""" if auth_service.is_configured(): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=http_status.HTTP_400_BAD_REQUEST, detail="Master password already configured", ) try: auth_service.setup_master_password(req.master_password) except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e return {"status": "ok"} @@ -32,53 +37,65 @@ def setup_auth(req: SetupRequest): @router.post("/login", response_model=LoginResponse) def login(req: LoginRequest): """Validate master password and return JWT token.""" - # Use a simple identifier for failed attempts; prefer IP in a real app + # Use a simple identifier for failed attempts; prefer IP in real app identifier = "global" try: - valid = auth_service.validate_master_password(req.password, identifier=identifier) - except AuthError as e: - raise HTTPException(status_code=400, detail=str(e)) + valid = auth_service.validate_master_password( + req.password, identifier=identifier + ) except LockedOutError as e: - raise HTTPException(status_code=429, detail=str(e)) + raise HTTPException( + status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, + detail=str(e), + ) from e + except AuthError as e: + raise HTTPException(status_code=400, detail=str(e)) from e if not valid: raise HTTPException(status_code=401, detail="Invalid credentials") - token = auth_service.create_access_token(subject="master", remember=bool(req.remember)) + token = auth_service.create_access_token( + subject="master", remember=bool(req.remember) + ) return token @router.post("/logout") -def logout(credentials: HTTPAuthorizationCredentials = None): +def logout_endpoint( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(optional_bearer), +): """Logout by revoking token (no-op for stateless JWT).""" - # Import security dependency lazily to avoid heavy imports during test - if credentials is None: - from fastapi import Depends - - from src.server.utils.dependencies import security as _security - - # Trigger dependency resolution during normal request handling - credentials = Depends(_security) - # If a plain credentials object was provided, extract token token = getattr(credentials, "credentials", None) - # Placeholder; auth_service.revoke_token can be expanded to persist revocations - auth_service.revoke_token(token) - return {"status": "ok"} + # Placeholder; auth_service.revoke_token can be expanded to persist + # revocations + if token: + auth_service.revoke_token(token) + return {"status": "ok", "message": "Logged out successfully"} + + +async def get_optional_auth( + credentials: Optional[HTTPAuthorizationCredentials] = Depends( + optional_bearer + ), +) -> Optional[dict]: + """Get optional authentication from bearer token.""" + if credentials is None: + return None + + token = credentials.credentials + try: + # Validate and decode token using the auth service + session = auth_service.create_session_model(token) + return session.dict() + except AuthError: + return None @router.get("/status", response_model=AuthStatus) -def status(auth: Optional[dict] = None): - """Return whether master password is configured and if caller is authenticated.""" - # Lazy import to avoid pulling in database/sqlalchemy during module import - from fastapi import Depends - try: - from src.server.utils.dependencies import optional_auth as _optional_auth - except Exception: - _optional_auth = None - - # If dependency injection didn't provide auth, attempt to resolve optionally - if auth is None and _optional_auth is not None: - auth = Depends(_optional_auth) - return AuthStatus(configured=auth_service.is_configured(), authenticated=bool(auth)) +async def auth_status(auth: Optional[dict] = Depends(get_optional_auth)): + """Return whether master password is configured and authenticated.""" + return AuthStatus( + configured=auth_service.is_configured(), authenticated=bool(auth) + ) diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index c59121a..965c98d 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -20,7 +20,8 @@ from src.core.SeriesApp import SeriesApp from src.server.services.auth_service import AuthError, auth_service # Security scheme for JWT authentication -security = HTTPBearer() +# Use auto_error=False to handle errors manually and return 401 instead of 403 +security = HTTPBearer(auto_error=False) # Global SeriesApp instance @@ -99,7 +100,7 @@ async def get_database_session() -> AsyncGenerator: def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security) + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ) -> dict: """ Dependency to get current authenticated user. @@ -128,7 +129,7 @@ def get_current_user( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e), - ) + ) from e def require_auth( diff --git a/tests/api/test_config_endpoints.py b/tests/api/test_config_endpoints.py index 4a8b366..ba19523 100644 --- a/tests/api/test_config_endpoints.py +++ b/tests/api/test_config_endpoints.py @@ -180,14 +180,14 @@ async def test_delete_backup(authenticated_client, mock_config_service): @pytest.mark.asyncio -async def test_config_persistence(client, mock_config_service): +async def test_config_persistence(authenticated_client, mock_config_service): """Test end-to-end configuration persistence.""" # Get initial config - resp = await client.get("/api/config") + resp = await authenticated_client.get("/api/config") assert resp.status_code == 200 initial = resp.json() # Validate it can be loaded again - resp2 = await client.get("/api/config") + resp2 = await authenticated_client.get("/api/config") assert resp2.status_code == 200 assert resp2.json() == initial diff --git a/tests/frontend/test_existing_ui_integration.py b/tests/frontend/test_existing_ui_integration.py index 2ba22e1..7aa2752 100644 --- a/tests/frontend/test_existing_ui_integration.py +++ b/tests/frontend/test_existing_ui_integration.py @@ -45,6 +45,16 @@ async def client(): @pytest.fixture async def authenticated_client(client): """Create authenticated test client with JWT token.""" + # Setup anime directory in settings + import tempfile + + from src.config.settings import settings + settings.anime_directory = tempfile.gettempdir() + + # Reset series app to pick up new directory + from src.server.utils.dependencies import reset_series_app + reset_series_app() + # Setup master password await client.post( "/api/auth/setup", @@ -142,20 +152,20 @@ class TestFrontendAuthentication: ) # Try to access protected endpoint without token - response = await client.get("/api/v1/anime") + response = await client.get("/api/v1/anime/") assert response.status_code == 401 async def test_authenticated_request_succeeds(self, authenticated_client): """Test that requests with valid token succeed.""" - with patch( - "src.server.services.anime_service.AnimeService" - ) as mock_service: - mock_instance = AsyncMock() - mock_instance.get_all_series = AsyncMock(return_value=[]) - mock_service.return_value = mock_instance + with patch("src.server.utils.dependencies.get_series_app") as mock_get_app: + mock_app = AsyncMock() + mock_list = AsyncMock() + mock_list.GetMissingEpisode = AsyncMock(return_value=[]) + mock_app.List = mock_list + mock_get_app.return_value = mock_app - response = await authenticated_client.get("/api/v1/anime") + response = await authenticated_client.get("/api/v1/anime/") assert response.status_code == 200 @@ -179,7 +189,7 @@ class TestFrontendAnimeAPI: ]) mock_get_service.return_value = mock_service - response = await authenticated_client.get("/api/v1/anime") + response = await authenticated_client.get("/api/v1/anime/") assert response.status_code == 200 data = response.json()