From d45a1eb375597ee4c5aaa16836d5074521611e0e Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sun, 17 May 2026 12:14:58 +0900 Subject: [PATCH] BE dry refact --- backend/app/routers/auth/endpoints.py | 23 +- backend/app/routers/auth/token_manager.py | 25 +- backend/app/routers/blog.py | 375 --------- backend/app/routers/blog/__init__.py | 21 + backend/app/routers/blog/accounts.py | 157 ++++ backend/app/routers/blog/score.py | 27 + backend/app/routers/blog/summarize.py | 170 ++++ backend/app/routers/blog/sync.py | 54 ++ backend/app/routers/career_analysis.py | 2 +- backend/app/routers/intelligence.py | 2 +- backend/app/routers/resumes.py | 52 +- backend/app/schemas/__init__.py | 2 +- backend/app/schemas/career_analysis.py | 9 +- backend/app/schemas/shared.py | 15 + .../markdown/generators/resume_generator.py | 27 +- .../pdf/generators/resume_generator.py | 26 +- backend/app/services/shared/resume_format.py | 40 + backend/tests/auth/__init__.py | 0 backend/tests/auth/test_endpoints.py | 250 ++++++ backend/tests/auth/test_oauth_flow.py | 325 ++++++++ backend/tests/auth/test_token_manager.py | 151 ++++ backend/tests/blog/__init__.py | 0 .../{test_blog.py => blog/test_accounts.py} | 252 +----- backend/tests/blog/test_score.py | 78 ++ backend/tests/blog/test_summarize.py | 107 +++ backend/tests/blog/test_sync.py | 152 ++++ backend/tests/security/__init__.py | 0 backend/tests/security/_helpers.py | 50 ++ .../security/test_admin_authorization.py | 62 ++ .../tests/security/test_boundary_values.py | 162 ++++ backend/tests/security/test_idor.py | 228 ++++++ backend/tests/security/test_no_auth.py | 66 ++ backend/tests/security/test_sql_injection.py | 105 +++ .../services/tasks/test_handlers_success.py | 129 +++ backend/tests/test_auth.py | 576 ------------- backend/tests/test_career_analysis_api.py | 6 +- backend/tests/test_oauth_flow.py | 121 --- backend/tests/test_retry_flow.py | 157 ++-- backend/tests/test_security_edges.py | 661 --------------- backend/tests/test_worker/__init__.py | 0 backend/tests/test_worker/_helpers.py | 12 + .../tests/test_worker/test_blog_summarize.py | 181 +++++ .../tests/test_worker/test_career_analysis.py | 162 ++++ .../tests/test_worker/test_execute_task.py | 138 ++++ .../tests/test_worker/test_github_analysis.py | 275 +++++++ backend/tests/test_worker_extended.py | 762 ------------------ frontend/src/api/paths.ts | 14 +- 47 files changed, 3253 insertions(+), 2956 deletions(-) delete mode 100644 backend/app/routers/blog.py create mode 100644 backend/app/routers/blog/__init__.py create mode 100644 backend/app/routers/blog/accounts.py create mode 100644 backend/app/routers/blog/score.py create mode 100644 backend/app/routers/blog/summarize.py create mode 100644 backend/app/routers/blog/sync.py create mode 100644 backend/app/services/shared/resume_format.py create mode 100644 backend/tests/auth/__init__.py create mode 100644 backend/tests/auth/test_endpoints.py create mode 100644 backend/tests/auth/test_oauth_flow.py create mode 100644 backend/tests/auth/test_token_manager.py create mode 100644 backend/tests/blog/__init__.py rename backend/tests/{test_blog.py => blog/test_accounts.py} (57%) create mode 100644 backend/tests/blog/test_score.py create mode 100644 backend/tests/blog/test_summarize.py create mode 100644 backend/tests/blog/test_sync.py create mode 100644 backend/tests/security/__init__.py create mode 100644 backend/tests/security/_helpers.py create mode 100644 backend/tests/security/test_admin_authorization.py create mode 100644 backend/tests/security/test_boundary_values.py create mode 100644 backend/tests/security/test_idor.py create mode 100644 backend/tests/security/test_no_auth.py create mode 100644 backend/tests/security/test_sql_injection.py create mode 100644 backend/tests/services/tasks/test_handlers_success.py delete mode 100644 backend/tests/test_auth.py delete mode 100644 backend/tests/test_oauth_flow.py delete mode 100644 backend/tests/test_security_edges.py create mode 100644 backend/tests/test_worker/__init__.py create mode 100644 backend/tests/test_worker/_helpers.py create mode 100644 backend/tests/test_worker/test_blog_summarize.py create mode 100644 backend/tests/test_worker/test_career_analysis.py create mode 100644 backend/tests/test_worker/test_execute_task.py create mode 100644 backend/tests/test_worker/test_github_analysis.py delete mode 100644 backend/tests/test_worker_extended.py diff --git a/backend/app/routers/auth/endpoints.py b/backend/app/routers/auth/endpoints.py index 38143bdc..1271f628 100644 --- a/backend/app/routers/auth/endpoints.py +++ b/backend/app/routers/auth/endpoints.py @@ -34,6 +34,7 @@ ) from .token_manager import ( clear_auth_cookies, + extract_refresh_token_from_session, set_auth_cookies, ) @@ -72,16 +73,7 @@ def refresh( db: Session = Depends(get_db), ) -> TokenResponse: """リフレッシュトークンで新しいアクセストークンを発行する。""" - # session Cookie に JSON 形式で格納した refresh_token を取り出す - raw = request.cookies.get("session") - token: str | None = None - if raw: - try: - data = _json.loads(raw) - if isinstance(data, dict): - token = data.get("refresh_token") - except (ValueError, TypeError): - pass + token = extract_refresh_token_from_session(request) if not token: raise_app_error( status_code=status.HTTP_401_UNAUTHORIZED, @@ -126,16 +118,7 @@ def logout( """ログアウト処理。DB の refresh_jti を無効化し Cookie を削除する。 トークン解析が失敗した場合でも必ず Cookie を削除して 204 を返す。 """ - # session Cookie に JSON 形式で格納した refresh_token を取り出す - raw = request.cookies.get("session") - token: str | None = None - if raw: - try: - data = _json.loads(raw) - if isinstance(data, dict): - token = data.get("refresh_token") - except (ValueError, TypeError): - logger.debug("ログアウト時の session Cookie パースに失敗(Cookie 削除を継続)", exc_info=True) + token = extract_refresh_token_from_session(request) if token: try: payload = _decode_token(token) diff --git a/backend/app/routers/auth/token_manager.py b/backend/app/routers/auth/token_manager.py index d1794b6c..4baaf3c3 100644 --- a/backend/app/routers/auth/token_manager.py +++ b/backend/app/routers/auth/token_manager.py @@ -3,9 +3,10 @@ """ import json +import logging import secrets -from fastapi import Response +from fastapi import Request, Response from sqlalchemy.orm import Session from ...core.security.auth import ( @@ -17,10 +18,32 @@ from ...core.settings import get_cookie_samesite, get_cookie_secure from ...repositories import UserRepository +logger = logging.getLogger(__name__) + # 認証セッション Cookie 名(state と redirect_url はフロントの sessionStorage で管理する) GITHUB_OAUTH_SESSION_COOKIE = "session" +def extract_refresh_token_from_session(request: Request) -> str | None: + """session Cookie に JSON で格納された refresh_token を取り出す。 + + refresh と logout の両方で同形のパース処理が必要なため集約する。 + Cookie 不在・JSON 不正・型不一致の場合は `None` を返す。 + """ + raw = request.cookies.get(GITHUB_OAUTH_SESSION_COOKIE) + if not raw: + return None + try: + data = json.loads(raw) + except (ValueError, TypeError): + logger.debug("session Cookie の JSON パースに失敗", exc_info=True) + return None + if not isinstance(data, dict): + return None + token = data.get("refresh_token") + return token if isinstance(token, str) else None + + def set_cookie(response: Response, key: str, value: str, max_age: int) -> None: """指定したキーと値で HttpOnly Cookie を設定する。""" response.set_cookie( diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py deleted file mode 100644 index 46e73f43..00000000 --- a/backend/app/routers/blog.py +++ /dev/null @@ -1,375 +0,0 @@ -""" -ブログ連携 API エンドポイント。 - -GET /api/blog/accounts — 連携アカウント一覧 -POST /api/blog/accounts — 連携アカウント登録 -PATCH /api/blog/accounts/{platform} — 連携アカウント更新 -DELETE /api/blog/accounts/{id} — 連携アカウント解除 -GET /api/blog/articles — 記事一覧 -POST /api/blog/accounts/{id}/sync — 手動同期 -POST /api/blog/summarize — AI サマリ生成(202 非同期) -GET /api/blog/summary-cache — 保存済みサマリ取得 -GET /api/blog/summary-cache/status — サマリ生成ステータスポーリング用 -GET /api/blog/score — ブログスコアリング -""" - -import logging -from dataclasses import asdict - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request -from sqlalchemy.orm import Session - -from ..core.errors import resolve_async_error_code -from ..core.messages import get_error -from ..core.security.auth import get_current_user -from ..core.security.dependencies import limiter -from ..db import get_db -from ..models import User -from ..repositories import BlogAccountRepository, BlogArticleRepository, BlogSummaryCacheRepository -from ..schemas import ( - BlogAccountCreate, - BlogAccountResponse, - BlogAccountUpdate, - BlogArticleResponse, - BlogScoreResponse, - BlogSummaryResponse, - BlogSyncResponse, -) -from ..schemas.career_analysis import TaskStatusResponse -from ..services.blog.account_service import BlogAccountService -from ..services.blog.collector import ( - BlogAccountNotFoundError, - BlogPlatformRequestError, - UnsupportedBlogPlatformError, - normalize_username, - verify_user_exists, -) -from ..services.blog.scorer import blog_articles_to_score_dicts, calculate_blog_score -from ..services.blog.sync_service import BlogSyncService -from ..services.intelligence.llm_summarizer import check_llm_available -from ..services.tasks import AsyncTaskCacheService, TaskType - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/blog", tags=["blog"]) - - -@router.get("/accounts", response_model=list[BlogAccountResponse]) -def list_accounts( - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """連携アカウント一覧を取得する。""" - repo = BlogAccountRepository(db, user.id) - return repo.list_by_user() - - -@router.post("/accounts", response_model=BlogAccountResponse, status_code=201) -@limiter.limit("10/minute") -async def add_account( - request: Request, - body: BlogAccountCreate, - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """連携アカウントを登録する。 - 同じプラットフォームは1つまで。ユーザー存在チェックあり。 - """ - repo = BlogAccountRepository(db, user.id) - existing = repo.get_by_platform(body.platform) - if existing: - raise HTTPException( - status_code=409, - detail=get_error("blog.account_already_registered"), - ) - - try: - normalized_username = normalize_username(body.platform, body.username) - except UnsupportedBlogPlatformError as exc: - raise HTTPException( - status_code=400, - detail=get_error("blog.platform_not_supported"), - ) from exc - except ValueError as exc: - raise HTTPException( - status_code=404, - detail=get_error("blog.account_not_found"), - ) from exc - - # 外部プラットフォーム上にユーザーが存在するか検証 - try: - user_exists = await verify_user_exists(body.platform, normalized_username) - except UnsupportedBlogPlatformError as exc: - raise HTTPException( - status_code=400, - detail=get_error("blog.platform_not_supported"), - ) from exc - except BlogPlatformRequestError as exc: - raise HTTPException( - status_code=502, - detail=get_error("blog.account_check_failed"), - ) from exc - - if not user_exists: - raise HTTPException( - status_code=404, - detail=get_error("blog.account_not_found"), - ) - - account = repo.upsert(body.platform, normalized_username) - return account - - -@router.patch("/accounts/{platform}", response_model=BlogAccountResponse) -@limiter.limit("10/minute") -async def update_account( - request: Request, - platform: str, - body: BlogAccountUpdate, - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """連携アカウントの username を更新し、同期状態を未同期に戻す。""" - service = BlogAccountService(db, user.id) - if not service.get_by_platform(platform): - raise HTTPException(status_code=404, detail=get_error("blog.account_link_not_found")) - - try: - return await service.update_username(platform, body.username) - except BlogAccountNotFoundError as exc: - raise HTTPException( - status_code=404, - detail=get_error("blog.account_not_found"), - ) from exc - except UnsupportedBlogPlatformError as exc: - raise HTTPException( - status_code=400, - detail=get_error("blog.platform_not_supported"), - ) from exc - except BlogPlatformRequestError as exc: - raise HTTPException( - status_code=502, - detail=get_error("blog.account_check_failed"), - ) from exc - except ValueError as exc: - raise HTTPException( - status_code=404, - detail=get_error("blog.account_not_found"), - ) from exc - - -@router.delete("/accounts/{account_id}", status_code=204) -def delete_account( - account_id: str, - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """連携アカウントを解除する。紐づく記事も削除される。""" - account_repo = BlogAccountRepository(db, user.id) - if not account_repo.delete(account_id): - raise HTTPException(status_code=404, detail=get_error("blog.account_link_not_found")) - - -@router.get("/articles", response_model=list[BlogArticleResponse]) -def list_articles( - platform: str | None = Query(default=None), - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """DB に保存済みの記事一覧を取得する。""" - repo = BlogArticleRepository(db, user.id) - return repo.list_by_user(platform=platform) - - -@router.post("/accounts/{account_id}/sync", response_model=BlogSyncResponse) -@limiter.limit("10/minute") -async def sync_account( - request: Request, - account_id: str, - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """外部 API からデータを取得して DB に保存する。""" - service = BlogSyncService(db, user.id) - if not service.get_account_or_none(account_id): - raise HTTPException(status_code=404, detail=get_error("blog.account_link_not_found")) - - try: - return await service.sync(account_id) - except BlogAccountNotFoundError as exc: - raise HTTPException( - status_code=404, - detail=get_error("blog.account_not_found"), - ) from exc - except UnsupportedBlogPlatformError as exc: - raise HTTPException( - status_code=400, - detail=get_error("blog.platform_not_supported"), - ) from exc - except Exception as exc: - # UnsupportedBlogPlatformError は上の except で先に捕捉される - raise HTTPException( - status_code=502, - detail=get_error("blog.sync_failed"), - ) from exc - - -@router.get("/summary-cache", response_model=BlogSummaryResponse) -def get_summary_cache( - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """保存済みのブログ AI 分析結果を取得する。期限切れキャッシュは無効扱いにする。""" - cache = BlogSummaryCacheRepository(db, user.id).get() - if cache and cache.summary: - return BlogSummaryResponse( - summary=cache.summary, - available=True, - status=cache.status, - error_message=cache.error_message, - error_code=resolve_async_error_code(cache.error_message), - ) - if cache: - return BlogSummaryResponse( - summary="", - available=False, - status=cache.status, - error_message=cache.error_message, - error_code=resolve_async_error_code(cache.error_message), - ) - return BlogSummaryResponse(summary="", available=False) - - -@router.get("/summary-cache/status", response_model=TaskStatusResponse) -def get_summary_cache_status( - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """サマリ生成ステータスを返す(軽量ポーリング用)。""" - cache = BlogSummaryCacheRepository(db, user.id).get() - if not cache: - return TaskStatusResponse(status="completed") - return TaskStatusResponse( - status=cache.status, - error_message=cache.error_message, - error_code=resolve_async_error_code(cache.error_message), - ) - - -@router.post("/summarize", response_model=BlogSummaryResponse, status_code=202) -@limiter.limit("5/minute") -async def summarize_blog( - request: Request, - background_tasks: BackgroundTasks, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ブログ記事の AI サマリをバックグラウンドで生成する。 - - 記事は worker 側で ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 - """ - cache = BlogSummaryCacheRepository(db, user.id).get_or_create() - service = AsyncTaskCacheService(db, cache) - - available = await check_llm_available() - if not available: - return BlogSummaryResponse(summary="", available=False) - - # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン - if not service.try_reset_to_pending(): - return BlogSummaryResponse( - summary=cache.summary or "", - available=False, - status=cache.status, - ) - - try: - await service.dispatch( - background_tasks, - TaskType.BLOG_SUMMARIZE, - {"user_id": user.id}, - failure_message="タスクの開始に失敗しました", - logger=logger, - ) - except Exception: - raise HTTPException( - status_code=500, - detail=get_error("task.dispatch_failed"), - ) - - return BlogSummaryResponse( - summary="", - available=False, - status="pending", - ) - - -@router.post("/summarize/retry", response_model=BlogSummaryResponse, status_code=202) -@limiter.limit("5/minute") -async def retry_summarize_blog( - request: Request, - background_tasks: BackgroundTasks, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """失敗したブログサマリタスクを手動で再実行する。 - - ``dead_letter`` 状態のキャッシュのみ再実行可能。記事は worker 側で - ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 - """ - cache = BlogSummaryCacheRepository(db, user.id).get() - if not cache: - raise HTTPException( - status_code=404, - detail="サマリキャッシュが見つかりません", - ) - service = AsyncTaskCacheService(db, cache) - if not service.is_retryable_terminal(): - raise HTTPException( - status_code=409, - detail=f"このタスクはリトライできない状態です(現在: {cache.status})", - ) - - available = await check_llm_available() - if not available: - return BlogSummaryResponse(summary="", available=False) - - # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ - if not service.try_reset_to_pending(reset_retry_count=True): - raise HTTPException( - status_code=409, - detail=f"このタスクはリトライできない状態です(現在: {cache.status})", - ) - - try: - await service.dispatch( - background_tasks, - TaskType.BLOG_SUMMARIZE, - {"user_id": user.id}, - failure_message="タスクの再実行に失敗しました", - logger=logger, - ) - except Exception: - raise HTTPException( - status_code=500, - detail=get_error("task.dispatch_failed"), - ) - - return BlogSummaryResponse( - summary="", - available=False, - status="pending", - ) - - -@router.get("/score", response_model=BlogScoreResponse) -def get_blog_score( - user: User = Depends(get_current_user), - db=Depends(get_db), -): - """保存済みの記事に対してスコアリングを実行する。""" - repo = BlogArticleRepository(db, user.id) - articles = repo.list_by_user() - - score = calculate_blog_score(blog_articles_to_score_dicts(articles)) - return BlogScoreResponse.model_validate(asdict(score)) diff --git a/backend/app/routers/blog/__init__.py b/backend/app/routers/blog/__init__.py new file mode 100644 index 00000000..904b5bd8 --- /dev/null +++ b/backend/app/routers/blog/__init__.py @@ -0,0 +1,21 @@ +""" +ブログ連携 API ルーターパッケージ。 + +責務別にサブモジュールへ分割している。 +- accounts: 連携アカウント CRUD と記事一覧 +- sync: 外部 API からの手動同期 +- summarize: AI サマリ生成・リトライ・キャッシュ取得・ステータスポーリング +- score: ブログスコアリング +""" + +from fastapi import APIRouter + +from . import accounts, score, summarize, sync + +router = APIRouter(prefix="/api/blog", tags=["blog"]) +router.include_router(accounts.router) +router.include_router(sync.router) +router.include_router(summarize.router) +router.include_router(score.router) + +__all__ = ["router"] diff --git a/backend/app/routers/blog/accounts.py b/backend/app/routers/blog/accounts.py new file mode 100644 index 00000000..5a7a3c8a --- /dev/null +++ b/backend/app/routers/blog/accounts.py @@ -0,0 +1,157 @@ +"""ブログ連携アカウント CRUD と記事一覧。""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.messages import get_error +from ...core.security.auth import get_current_user +from ...core.security.dependencies import limiter +from ...db import get_db +from ...models import User +from ...repositories import BlogAccountRepository, BlogArticleRepository +from ...schemas import ( + BlogAccountCreate, + BlogAccountResponse, + BlogAccountUpdate, + BlogArticleResponse, +) +from ...services.blog.account_service import BlogAccountService +from ...services.blog.collector import ( + BlogAccountNotFoundError, + BlogPlatformRequestError, + UnsupportedBlogPlatformError, + normalize_username, + verify_user_exists, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/accounts", response_model=list[BlogAccountResponse]) +def list_accounts( + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """連携アカウント一覧を取得する。""" + repo = BlogAccountRepository(db, user.id) + return repo.list_by_user() + + +@router.post("/accounts", response_model=BlogAccountResponse, status_code=201) +@limiter.limit("10/minute") +async def add_account( + request: Request, + body: BlogAccountCreate, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """連携アカウントを登録する。 + 同じプラットフォームは1つまで。ユーザー存在チェックあり。 + """ + repo = BlogAccountRepository(db, user.id) + existing = repo.get_by_platform(body.platform) + if existing: + raise HTTPException( + status_code=409, + detail=get_error("blog.account_already_registered"), + ) + + try: + normalized_username = normalize_username(body.platform, body.username) + except UnsupportedBlogPlatformError as exc: + raise HTTPException( + status_code=400, + detail=get_error("blog.platform_not_supported"), + ) from exc + except ValueError as exc: + raise HTTPException( + status_code=404, + detail=get_error("blog.account_not_found"), + ) from exc + + # 外部プラットフォーム上にユーザーが存在するか検証 + try: + user_exists = await verify_user_exists(body.platform, normalized_username) + except UnsupportedBlogPlatformError as exc: + raise HTTPException( + status_code=400, + detail=get_error("blog.platform_not_supported"), + ) from exc + except BlogPlatformRequestError as exc: + raise HTTPException( + status_code=502, + detail=get_error("blog.account_check_failed"), + ) from exc + + if not user_exists: + raise HTTPException( + status_code=404, + detail=get_error("blog.account_not_found"), + ) + + account = repo.upsert(body.platform, normalized_username) + return account + + +@router.patch("/accounts/{platform}", response_model=BlogAccountResponse) +@limiter.limit("10/minute") +async def update_account( + request: Request, + platform: str, + body: BlogAccountUpdate, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """連携アカウントの username を更新し、同期状態を未同期に戻す。""" + service = BlogAccountService(db, user.id) + if not service.get_by_platform(platform): + raise HTTPException(status_code=404, detail=get_error("blog.account_link_not_found")) + + try: + return await service.update_username(platform, body.username) + except BlogAccountNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=get_error("blog.account_not_found"), + ) from exc + except UnsupportedBlogPlatformError as exc: + raise HTTPException( + status_code=400, + detail=get_error("blog.platform_not_supported"), + ) from exc + except BlogPlatformRequestError as exc: + raise HTTPException( + status_code=502, + detail=get_error("blog.account_check_failed"), + ) from exc + except ValueError as exc: + raise HTTPException( + status_code=404, + detail=get_error("blog.account_not_found"), + ) from exc + + +@router.delete("/accounts/{account_id}", status_code=204) +def delete_account( + account_id: str, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """連携アカウントを解除する。紐づく記事も削除される。""" + account_repo = BlogAccountRepository(db, user.id) + if not account_repo.delete(account_id): + raise HTTPException(status_code=404, detail=get_error("blog.account_link_not_found")) + + +@router.get("/articles", response_model=list[BlogArticleResponse]) +def list_articles( + platform: str | None = Query(default=None), + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """DB に保存済みの記事一覧を取得する。""" + repo = BlogArticleRepository(db, user.id) + return repo.list_by_user(platform=platform) diff --git a/backend/app/routers/blog/score.py b/backend/app/routers/blog/score.py new file mode 100644 index 00000000..81cf6381 --- /dev/null +++ b/backend/app/routers/blog/score.py @@ -0,0 +1,27 @@ +"""ブログスコアリング エンドポイント。""" + +from dataclasses import asdict + +from fastapi import APIRouter, Depends + +from ...core.security.auth import get_current_user +from ...db import get_db +from ...models import User +from ...repositories import BlogArticleRepository +from ...schemas import BlogScoreResponse +from ...services.blog.scorer import blog_articles_to_score_dicts, calculate_blog_score + +router = APIRouter() + + +@router.get("/score", response_model=BlogScoreResponse) +def get_blog_score( + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """保存済みの記事に対してスコアリングを実行する。""" + repo = BlogArticleRepository(db, user.id) + articles = repo.list_by_user() + + score = calculate_blog_score(blog_articles_to_score_dicts(articles)) + return BlogScoreResponse.model_validate(asdict(score)) diff --git a/backend/app/routers/blog/summarize.py b/backend/app/routers/blog/summarize.py new file mode 100644 index 00000000..93d23c15 --- /dev/null +++ b/backend/app/routers/blog/summarize.py @@ -0,0 +1,170 @@ +"""ブログ AI サマリ生成・リトライ・キャッシュ取得・ステータスポーリング。""" + +import logging + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from ...core.errors import resolve_async_error_code +from ...core.messages import get_error +from ...core.security.auth import get_current_user +from ...core.security.dependencies import limiter +from ...db import get_db +from ...models import User +from ...repositories import BlogSummaryCacheRepository +from ...schemas import BlogSummaryResponse +from ...schemas.shared import TaskStatusResponse +from ...services.intelligence.llm_summarizer import check_llm_available +from ...services.tasks import AsyncTaskCacheService, TaskType + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/summary-cache", response_model=BlogSummaryResponse) +def get_summary_cache( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """保存済みのブログ AI 分析結果を取得する。期限切れキャッシュは無効扱いにする。""" + cache = BlogSummaryCacheRepository(db, user.id).get() + if cache and cache.summary: + return BlogSummaryResponse( + summary=cache.summary, + available=True, + status=cache.status, + error_message=cache.error_message, + error_code=resolve_async_error_code(cache.error_message), + ) + if cache: + return BlogSummaryResponse( + summary="", + available=False, + status=cache.status, + error_message=cache.error_message, + error_code=resolve_async_error_code(cache.error_message), + ) + return BlogSummaryResponse(summary="", available=False) + + +@router.get("/summary-cache/status", response_model=TaskStatusResponse) +def get_summary_cache_status( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """サマリ生成ステータスを返す(軽量ポーリング用)。""" + cache = BlogSummaryCacheRepository(db, user.id).get() + if not cache: + return TaskStatusResponse(status="completed") + return TaskStatusResponse( + status=cache.status, + error_message=cache.error_message, + error_code=resolve_async_error_code(cache.error_message), + ) + + +@router.post("/summarize", response_model=BlogSummaryResponse, status_code=202) +@limiter.limit("5/minute") +async def summarize_blog( + request: Request, + background_tasks: BackgroundTasks, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ブログ記事の AI サマリをバックグラウンドで生成する。 + + 記事は worker 側で ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 + """ + cache = BlogSummaryCacheRepository(db, user.id).get_or_create() + service = AsyncTaskCacheService(db, cache) + + available = await check_llm_available() + if not available: + return BlogSummaryResponse(summary="", available=False) + + # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン + if not service.try_reset_to_pending(): + return BlogSummaryResponse( + summary=cache.summary or "", + available=False, + status=cache.status, + ) + + try: + await service.dispatch( + background_tasks, + TaskType.BLOG_SUMMARIZE, + {"user_id": user.id}, + failure_message="タスクの開始に失敗しました", + logger=logger, + ) + except Exception: + raise HTTPException( + status_code=500, + detail=get_error("task.dispatch_failed"), + ) + + return BlogSummaryResponse( + summary="", + available=False, + status="pending", + ) + + +@router.post("/summarize/retry", response_model=BlogSummaryResponse, status_code=202) +@limiter.limit("5/minute") +async def retry_summarize_blog( + request: Request, + background_tasks: BackgroundTasks, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """失敗したブログサマリタスクを手動で再実行する。 + + ``dead_letter`` 状態のキャッシュのみ再実行可能。記事は worker 側で + ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 + """ + cache = BlogSummaryCacheRepository(db, user.id).get() + if not cache: + raise HTTPException( + status_code=404, + detail="サマリキャッシュが見つかりません", + ) + service = AsyncTaskCacheService(db, cache) + if not service.is_retryable_terminal(): + raise HTTPException( + status_code=409, + detail=f"このタスクはリトライできない状態です(現在: {cache.status})", + ) + + available = await check_llm_available() + if not available: + return BlogSummaryResponse(summary="", available=False) + + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise HTTPException( + status_code=409, + detail=f"このタスクはリトライできない状態です(現在: {cache.status})", + ) + + try: + await service.dispatch( + background_tasks, + TaskType.BLOG_SUMMARIZE, + {"user_id": user.id}, + failure_message="タスクの再実行に失敗しました", + logger=logger, + ) + except Exception: + raise HTTPException( + status_code=500, + detail=get_error("task.dispatch_failed"), + ) + + return BlogSummaryResponse( + summary="", + available=False, + status="pending", + ) diff --git a/backend/app/routers/blog/sync.py b/backend/app/routers/blog/sync.py new file mode 100644 index 00000000..2432ce36 --- /dev/null +++ b/backend/app/routers/blog/sync.py @@ -0,0 +1,54 @@ +"""ブログアカウント手動同期エンドポイント。""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request + +from ...core.messages import get_error +from ...core.security.auth import get_current_user +from ...core.security.dependencies import limiter +from ...db import get_db +from ...models import User +from ...schemas import BlogSyncResponse +from ...services.blog.collector import ( + BlogAccountNotFoundError, + UnsupportedBlogPlatformError, +) +from ...services.blog.sync_service import BlogSyncService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/accounts/{account_id}/sync", response_model=BlogSyncResponse) +@limiter.limit("10/minute") +async def sync_account( + request: Request, + account_id: str, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """外部 API からデータを取得して DB に保存する。""" + service = BlogSyncService(db, user.id) + if not service.get_account_or_none(account_id): + raise HTTPException(status_code=404, detail=get_error("blog.account_link_not_found")) + + try: + return await service.sync(account_id) + except BlogAccountNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=get_error("blog.account_not_found"), + ) from exc + except UnsupportedBlogPlatformError as exc: + raise HTTPException( + status_code=400, + detail=get_error("blog.platform_not_supported"), + ) from exc + except Exception as exc: + # UnsupportedBlogPlatformError は上の except で先に捕捉される + raise HTTPException( + status_code=502, + detail=get_error("blog.sync_failed"), + ) from exc diff --git a/backend/app/routers/career_analysis.py b/backend/app/routers/career_analysis.py index b36a3dde..f220ab6a 100644 --- a/backend/app/routers/career_analysis.py +++ b/backend/app/routers/career_analysis.py @@ -25,8 +25,8 @@ CareerAnalysisGenerateRequest, CareerAnalysisResponse, CareerAnalysisResult, - TaskStatusResponse, ) +from ..schemas.shared import TaskStatusResponse from ..services.intelligence.llm import get_llm_client from ..services.tasks import AsyncTaskCacheService, TaskType diff --git a/backend/app/routers/intelligence.py b/backend/app/routers/intelligence.py index a2cf7d39..bd855825 100644 --- a/backend/app/routers/intelligence.py +++ b/backend/app/routers/intelligence.py @@ -18,13 +18,13 @@ from ..core.security.dependencies import limiter from ..db import get_db from ..models import GitHubAnalysisCache, User -from ..schemas.career_analysis import TaskStatusResponse from ..schemas.intelligence import ( AnalyzeRequest, CachedAnalysisResponse, PositionAdviceResponse, ProgressResponse, ) +from ..schemas.shared import TaskStatusResponse from ..services.intelligence.llm_advice_service import LLMPositionAdviceService from ..services.tasks import AsyncTaskCacheService, TaskType diff --git a/backend/app/routers/resumes.py b/backend/app/routers/resumes.py index b98c6d3f..8f8e4180 100644 --- a/backend/app/routers/resumes.py +++ b/backend/app/routers/resumes.py @@ -7,7 +7,7 @@ from ..core.messages import get_error, get_success from ..core.security.auth import get_current_user from ..db import get_db -from ..models import User +from ..models import Resume, User from ..repositories import ResumeRepository from ..schemas import ResumeCreate, ResumeResponse, ResumeUpdate from ..services.markdown.generators.resume_generator import ( @@ -19,7 +19,7 @@ router = APIRouter(prefix="/api/resumes", tags=["resumes"]) -def _resume_to_payload(resume) -> dict: +def _resume_to_payload(resume: Resume) -> dict: """Resume ORM から PDF/Markdown 生成用 payload を組み立てる。""" return { "full_name": resume.full_name, @@ -30,6 +30,20 @@ def _resume_to_payload(resume) -> dict: } +def _get_resume_or_404(repository: ResumeRepository, resume_id: uuid.UUID) -> Resume: + """指定 ID の Resume を取得し、未存在なら 404 を上げる。 + + `get_by_id → if not resume: raise HTTPException(404, ...)` の同一パターンを集約。 + """ + resume = repository.get_by_id(str(resume_id)) + if not resume: + raise HTTPException( + status_code=404, + detail=get_error("document.not_found", document="職務経歴書"), + ) + return resume + + @router.post("", response_model=ResumeResponse, status_code=201) def create_resume( payload: ResumeCreate, @@ -68,13 +82,7 @@ def get_resume( current_user: User = Depends(get_current_user), ) -> ResumeResponse: repository = ResumeRepository(db, current_user.id) - resume = repository.get_by_id(str(resume_id)) - if not resume: - raise HTTPException( - status_code=404, - detail=get_error("document.not_found", document="職務経歴書"), - ) - return resume + return _get_resume_or_404(repository, resume_id) @router.put("/{resume_id}", response_model=ResumeResponse) @@ -85,13 +93,7 @@ def update_resume( current_user: User = Depends(get_current_user), ) -> ResumeResponse: repository = ResumeRepository(db, current_user.id) - resume = repository.get_by_id(str(resume_id)) - if not resume: - raise HTTPException( - status_code=404, - detail=get_error("document.not_found", document="職務経歴書"), - ) - + resume = _get_resume_or_404(repository, resume_id) return repository.update(resume, payload.model_dump()) @@ -116,14 +118,7 @@ def download_resume_pdf( current_user: User = Depends(get_current_user), ) -> StreamingResponse: repository = ResumeRepository(db, current_user.id) - - resume = repository.get_by_id(str(resume_id)) - if not resume: - raise HTTPException( - status_code=404, - detail=get_error("document.not_found", document="職務経歴書"), - ) - + resume = _get_resume_or_404(repository, resume_id) pdf_bytes = build_resume_pdf(_resume_to_payload(resume)) return stream_pdf(pdf_bytes, f"career-resume-{resume.id}.pdf") @@ -135,13 +130,6 @@ def download_resume_markdown( current_user: User = Depends(get_current_user), ) -> StreamingResponse: repository = ResumeRepository(db, current_user.id) - - resume = repository.get_by_id(str(resume_id)) - if not resume: - raise HTTPException( - status_code=404, - detail=get_error("document.not_found", document="職務経歴書"), - ) - + resume = _get_resume_or_404(repository, resume_id) md_text = build_resume_markdown(_resume_to_payload(resume)) return stream_markdown(md_text, f"career-resume-{resume.id}.md") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index bc15970b..9b406bba 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -20,7 +20,6 @@ CareerAnalysisResult, CareerPathItem, StrengthItem, - TaskStatusResponse, TechStackItem, TechStackSection, ) @@ -51,6 +50,7 @@ TeamMember, TechnologyStackItem, ) +from .shared import TaskStatusResponse __all__ = [ "ActionItem", diff --git a/backend/app/schemas/career_analysis.py b/backend/app/schemas/career_analysis.py index b9d13d3a..125f6ed3 100644 --- a/backend/app/schemas/career_analysis.py +++ b/backend/app/schemas/career_analysis.py @@ -4,6 +4,9 @@ from pydantic import BaseModel, ConfigDict, Field +# 互換のため再エクスポート。新規実装は schemas.shared から import すること。 +from .shared import TaskStatusResponse # noqa: F401 + class TechStackItem(BaseModel): """技術スタック1件。""" @@ -79,9 +82,3 @@ class CareerAnalysisResponse(BaseModel): model_config = ConfigDict(from_attributes=True) -class TaskStatusResponse(BaseModel): - """タスクステータスの軽量レスポンス。""" - - status: str - error_message: str | None = None - error_code: str | None = None diff --git a/backend/app/schemas/shared.py b/backend/app/schemas/shared.py index bdec9df7..401220ec 100644 --- a/backend/app/schemas/shared.py +++ b/backend/app/schemas/shared.py @@ -1 +1,16 @@ +"""ドメイン横断で共用する Pydantic スキーマ。""" + +from pydantic import BaseModel + _HIRAGANA_PATTERN = r"^[ぁ-ゖー\s ]+$" + + +class TaskStatusResponse(BaseModel): + """非同期タスクのステータスを返す軽量レスポンス。 + + blog / career_analysis / intelligence など複数の router で共通利用される。 + """ + + status: str + error_message: str | None = None + error_code: str | None = None diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index fc5f4877..027e31fb 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -1,16 +1,11 @@ from typing import Any +from ...shared.resume_format import CATEGORY_LABELS +from ...shared.resume_format import attr as _a from ..templates import resume_template as tpl from ..utils.markdown_utils import field_line, format_period -def _a(obj, key, default=""): - """dict / ORM オブジェクト両対応の属性アクセス""" - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) - - def build_resume_markdown(payload: dict[str, Any]) -> str: lines: list[str] = [] lines.append(tpl.TITLE) @@ -122,21 +117,6 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: lines.append(field_line("工程", ", ".join(phases))) stacks = _a(proj, "technology_stacks", []) if stacks: - cat_labels = { - "language": "言語", - "framework": "FW", - "os": "OS", - "db": "DB", - "cloud_provider": "クラウド", - "container": "コンテナ", - "iac": "IaC", - "vcs": "バージョン管理", - "ci_cd": "CI/CD", - "project_tool": "プロジェクトツール", - "monitoring": "監視・可観測性", - "middleware": "ミドルウェア", - "ai_agent": "AIエージェント", - } grouped: dict[str, list[str]] = {} for st in stacks: cat = _a(st, "category") @@ -144,7 +124,8 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: grouped[cat] = [] grouped[cat].append(_a(st, "name")) parts = [ - f"{cat_labels.get(c, c)}: {', '.join(ns)}" for c, ns in grouped.items() + f"{CATEGORY_LABELS.get(c, c)}: {', '.join(ns)}" + for c, ns in grouped.items() ] lines.append(field_line("技術スタック", " / ".join(parts))) lines.append("") diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index a4bb851c..70dbdec5 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -6,6 +6,8 @@ import weasyprint from ....core.date_utils import JST +from ...shared.resume_format import CATEGORY_LABELS as _CATEGORY_LABELS +from ...shared.resume_format import attr as _a _CSS_PATH = Path(__file__).resolve().parent.parent / "templates" / "resume.css" _FONT_PATH = ( @@ -13,30 +15,6 @@ ) -def _a(obj, key, default=""): - """dict / ORM オブジェクト両対応の属性アクセス""" - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) - - -_CATEGORY_LABELS = { - "language": "言語", - "framework": "FW", - "os": "OS", - "db": "DB", - "cloud_provider": "クラウド", - "container": "コンテナ", - "iac": "IaC", - "vcs": "バージョン管理", - "ci_cd": "CI/CD", - "project_tool": "プロジェクトツール", - "monitoring": "監視・可観測性", - "middleware": "ミドルウェア", - "ai_agent": "AIエージェント", -} - - def _esc(text: str) -> str: """HTMLエスケープのショートカット""" return _html_escape(str(text)) diff --git a/backend/app/services/shared/resume_format.py b/backend/app/services/shared/resume_format.py new file mode 100644 index 00000000..41a1327c --- /dev/null +++ b/backend/app/services/shared/resume_format.py @@ -0,0 +1,40 @@ +""" +職務経歴書フォーマット用の共通ユーティリティ。 + +PDF(HTML 経由)と Markdown の両ジェネレータで重複していた +- 技術スタックカテゴリの日本語ラベル +- dict / ORM 両対応の属性アクセス + +を集約する。 + +注意: +- 期間表示の `format_period` は PDF(「YYYY 年 MM 月〜現在」)と Markdown(「YYYY-MM - 現在」)で + 意図的に出力フォーマットが異なる。共通化すると出力崩れが起きるためここには置かない。 +- HTML エスケープと Markdown エスケープも別物のためここでは扱わない。 +""" + +from typing import Any + +#: 技術スタックカテゴリの日本語ラベル。PDF / Markdown で共通利用する。 +CATEGORY_LABELS: dict[str, str] = { + "language": "言語", + "framework": "FW", + "os": "OS", + "db": "DB", + "cloud_provider": "クラウド", + "container": "コンテナ", + "iac": "IaC", + "vcs": "バージョン管理", + "ci_cd": "CI/CD", + "project_tool": "プロジェクトツール", + "monitoring": "監視・可観測性", + "middleware": "ミドルウェア", + "ai_agent": "AIエージェント", +} + + +def attr(obj: Any, key: str, default: Any = "") -> Any: + """dict / ORM オブジェクト両対応の属性アクセスヘルパ。""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) diff --git a/backend/tests/auth/__init__.py b/backend/tests/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/auth/test_endpoints.py b/backend/tests/auth/test_endpoints.py new file mode 100644 index 00000000..e59e2f7f --- /dev/null +++ b/backend/tests/auth/test_endpoints.py @@ -0,0 +1,250 @@ +"""routers/auth/endpoints に対応する統合テスト。 + +- /auth/refresh のアクセス/リフレッシュトークン分離 +- /auth/logout の Cookie 削除と refresh_jti 失効 +- /auth/me の現在ユーザー返却 +- CSRF チェック +- 認証失敗ログ +- rate limit +""" + +import json +import logging + +from app.core.security.auth import ( + create_access_token, + create_refresh_token, +) +from app.main import limiter + +from conftest import auth_header + +# ── /auth/refresh ──────────────────────────────────────────────── + + +def test_access_token_cannot_be_used_as_refresh(client) -> None: + """アクセストークンでリフレッシュエンドポイントを叩いて拒否されることを確認する。""" + auth_header(client, "rftest") + # session から access_token を取り出し、refresh_token として上書きする + raw = client.cookies.get("session", "{}") + data = json.loads(raw) + data["refresh_token"] = data.get("access_token", "") + client.cookies.set("session", json.dumps(data)) + + response = client.post("/auth/refresh") + + assert response.status_code == 401 + + +def test_refresh_token_cannot_access_api(client) -> None: + """リフレッシュトークンで通常 API を叩いて拒否されることを確認する。""" + auth_header(client, "apitest") + # session の access_token をリフレッシュトークンで上書きする + refresh_token, _ = create_refresh_token("apitest") + raw = client.cookies.get("session", "{}") + data = json.loads(raw) + data["access_token"] = refresh_token + client.cookies.set("session", json.dumps(data)) + + response = client.get("/auth/me") + + assert response.status_code == 401 + + +def test_refresh_issues_new_access_token(client) -> None: + """有効なリフレッシュトークンで新しいアクセストークンが発行されることを確認する。""" + auth_header(client, "refuser") + + response = client.post("/auth/refresh") + + assert response.status_code == 200 + assert response.json()["username"] == "refuser" + assert "access_token=" in response.headers.get("set-cookie", "") + + +def test_refresh_rejects_revoked_jti(client) -> None: + """DB の refresh_jti と一致しないトークンでリフレッシュが拒否されることを確認する。""" + auth_header(client, "revokeduser") + # jti が DB と一致しない別トークンを発行し、session の refresh_token を上書きする + stale_token, _ = create_refresh_token("revokeduser") + raw = client.cookies.get("session", "{}") + data = json.loads(raw) + data["refresh_token"] = stale_token + client.cookies.set("session", json.dumps(data)) + + response = client.post("/auth/refresh") + + assert response.status_code == 401 + + +# ── /auth/logout ───────────────────────────────────────────────── + + +def test_logout_clears_cookies(client) -> None: + """ログアウト後に認証 Cookie が削除されることを確認する。""" + auth_header(client, "logoutuser") + + response = client.post("/auth/logout") + + assert response.status_code == 204 + set_cookie = response.headers.get("set-cookie", "") + assert "access_token=" in set_cookie + + +def test_logout_invalidates_refresh_token(client) -> None: + """ログアウト後にリフレッシュトークンで再認証できないことを確認する。""" + auth_header(client, "logoutuser2") + + client.post("/auth/logout") + response = client.post("/auth/refresh") + + assert response.status_code == 401 + + +def test_logout_without_token_returns_204(client) -> None: + """リフレッシュトークンなしでもログアウトが 204 を返すことを確認する。""" + response = client.post("/auth/logout") + + assert response.status_code == 204 + + +# ── /auth/me ───────────────────────────────────────────────────── + + +def test_me_returns_current_user(client) -> None: + auth_header(client, "alice") + + response = client.get("/auth/me") + + assert response.status_code == 200 + assert response.json() == { + "username": "alice", + "is_github_user": False, + } + + +# ── CSRF ───────────────────────────────────────────────────────── + + +def _csrf_resume_payload() -> dict: + return { + "full_name": "テスト", + "career_summary": "要約", + "self_pr": "自己PR", + "experiences": [], + "qualifications": [], + } + + +def test_post_without_csrf_token_is_rejected(client) -> None: + """CSRFトークンなしの POST リクエストが 403 で拒否されることを確認する。""" + auth_header(client, "csrftest1") + client.cookies.delete("csrf_token") + + response = client.post( + "/api/resumes", + json=_csrf_resume_payload(), + # CSRF ヘッダーなし + ) + + assert response.status_code == 403 + + +def test_post_with_invalid_csrf_token_is_rejected(client) -> None: + """不正な CSRFトークンの POST リクエストが 403 で拒否されることを確認する。""" + auth_header(client, "csrftest2") + + response = client.post( + "/api/resumes", + json=_csrf_resume_payload(), + headers={"X-CSRF-Token": "invalid-token"}, + ) + + assert response.status_code == 403 + + +def test_post_with_valid_csrf_token_succeeds(client) -> None: + """正しい CSRF トークンの POST リクエストが成功することを確認する。""" + headers = auth_header(client, "csrftest3") + + response = client.post( + "/api/resumes", + json=_csrf_resume_payload(), + headers=headers, + ) + + assert response.status_code == 201 + + +def test_get_request_skips_csrf_check(client) -> None: + """GET リクエストは CSRF チェックをスキップすることを確認する。""" + auth_header(client, "csrftest4") + + response = client.get("/auth/me") + + assert response.status_code == 200 + + +# ── 不正アクセス追跡: 認証失敗ログ ──────────────────────────────── + + +def _auth_failed_records(records: list[logging.LogRecord]) -> list[logging.LogRecord]: + """auth_failed イベント (devforge ロガー WARNING) のみ抽出する。""" + return [r for r in records if r.name == "devforge" and r.message == "auth_failed"] + + +def test_auth_failed_logged_when_cookie_missing(client, caplog) -> None: + """Cookie 無しで保護 API を叩くと reason=missing_cookie の WARNING が出ることを確認する。""" + client.cookies.clear() + with caplog.at_level(logging.WARNING, logger="devforge"): + response = client.get("/auth/me") + + assert response.status_code == 401 + records = _auth_failed_records(caplog.records) + assert any(getattr(r, "reason", None) == "missing_cookie" for r in records) + + +def test_auth_failed_logged_for_invalid_jwt(client, caplog) -> None: + """不正な JWT で 401 + reason=jwt_decode_error がログされることを確認する。""" + client.cookies.clear() + client.cookies.set("session", json.dumps({"access_token": "not-a-valid-jwt"})) + with caplog.at_level(logging.WARNING, logger="devforge"): + response = client.get("/auth/me") + + assert response.status_code == 401 + records = _auth_failed_records(caplog.records) + assert any(getattr(r, "reason", None) == "jwt_decode_error" for r in records) + + +def test_auth_failed_logged_for_user_not_found(client, caplog) -> None: + """DB に存在しないユーザー名のトークンで reason=user_not_found がログされることを確認する。""" + token = create_access_token("nonexistent-user-xyz") + client.cookies.clear() + client.cookies.set("session", json.dumps({"access_token": token})) + with caplog.at_level(logging.WARNING, logger="devforge"): + response = client.get("/auth/me") + + assert response.status_code == 401 + records = _auth_failed_records(caplog.records) + assert any(getattr(r, "reason", None) == "user_not_found" for r in records) + + +# ── レートリミット ────────────────────────────────────────────── + + +def test_auth_me_rate_limited_after_threshold(client) -> None: + """/auth/me が 60/分の上限を超えると 429 を返すことを確認する。""" + auth_header(client, "rl-user") + limiter.reset() + statuses: list[int] = [] + for _i in range(65): + resp = client.get("/auth/me") + statuses.append(resp.status_code) + if resp.status_code == 429: + break + assert 429 in statuses + # 上限到達前に少なくとも数件は 200 を返している + assert statuses.count(200) >= 50 + limiter.reset() + + diff --git a/backend/tests/auth/test_oauth_flow.py b/backend/tests/auth/test_oauth_flow.py new file mode 100644 index 00000000..6dfafd75 --- /dev/null +++ b/backend/tests/auth/test_oauth_flow.py @@ -0,0 +1,325 @@ +"""routers/auth/oauth_flow とそれを経由する GitHub OAuth フローの統合テスト。 + +ユニット部: +- state 検証 +- frontend URL のスキーム / netloc / CORS_ORIGINS 検証 +- Cookie からの URL 解決 + +統合部: +- /auth/github/login-url の authorization_url / state / redirect_uri +- /auth/github/login の 303 リダイレクト +- /auth/github/callback (GET / POST) のトークン交換と Cookie 発行 +""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlparse + +import pytest +from app.routers.auth.oauth_flow import ( + normalize_frontend_url, + resolve_frontend_url_from_cookie, + validate_github_oauth_state, +) +from fastapi import HTTPException + +# ── state 検証テスト ───────────────────────────────────────────────────── + + +def test_validate_state_success() -> None: + """state が一致する場合は例外が発生しないこと。""" + validate_github_oauth_state("abc123", "abc123") + + +def test_validate_state_mismatch_raises_401() -> None: + """state が不一致の場合は HTTP 401 が発生すること。""" + with pytest.raises(HTTPException) as exc_info: + validate_github_oauth_state("abc123", "xyz789") + assert exc_info.value.status_code == 401 + + +def test_validate_state_missing_stored_raises_401() -> None: + """stored_state が None の場合は HTTP 401 が発生すること。""" + with pytest.raises(HTTPException) as exc_info: + validate_github_oauth_state(None, "abc123") + assert exc_info.value.status_code == 401 + + +def test_validate_state_missing_provided_raises_401() -> None: + """provided_state が None の場合は HTTP 401 が発生すること。""" + with pytest.raises(HTTPException) as exc_info: + validate_github_oauth_state("abc123", None) + assert exc_info.value.status_code == 401 + + +# ── malformed redirect_uri テスト ─────────────────────────────────────── + + +def test_normalize_frontend_url_invalid_scheme_raises_400() -> None: + """http/https 以外のスキームでは HTTP 400 が発生すること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://example.com"], + ): + with pytest.raises(HTTPException) as exc_info: + normalize_frontend_url("ftp://example.com/path") + assert exc_info.value.status_code == 400 + + +def test_normalize_frontend_url_no_netloc_raises_400() -> None: + """netloc が空の場合は HTTP 400 が発生すること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://example.com"], + ): + with pytest.raises(HTTPException) as exc_info: + normalize_frontend_url("https://") + assert exc_info.value.status_code == 400 + + +# ── frontend URL 許可リスト外テスト ───────────────────────────────────── + + +def test_normalize_frontend_url_not_in_cors_origins_raises_400() -> None: + """CORS_ORIGINS に含まれないオリジンでは HTTP 400 が発生すること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://allowed.example.com"], + ): + with pytest.raises(HTTPException) as exc_info: + normalize_frontend_url("https://evil.example.com/page") + assert exc_info.value.status_code == 400 + + +def test_normalize_frontend_url_allowed_origin_succeeds() -> None: + """CORS_ORIGINS に含まれるオリジンは正常に正規化されること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://allowed.example.com"], + ): + result = normalize_frontend_url("https://allowed.example.com/dashboard") + assert result == "https://allowed.example.com/dashboard" + + +# ── cookie からの URL 解決テスト ──────────────────────────────────────── + + +def test_resolve_frontend_url_from_cookie_valid() -> None: + """有効な Cookie URL が正規化されて返ること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://example.com"], + ): + result = resolve_frontend_url_from_cookie("https://example.com/") + assert result == "https://example.com/" + + +def test_resolve_frontend_url_from_cookie_none_returns_default() -> None: + """Cookie が None の場合はデフォルト URL が返ること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://default.example.com"], + ): + result = resolve_frontend_url_from_cookie(None) + assert result == "https://default.example.com/" + + +def test_resolve_frontend_url_from_cookie_invalid_returns_default() -> None: + """Cookie に不正な URL がある場合はデフォルト URL が返ること。""" + with patch( + "app.routers.auth.oauth_flow.get_cors_origins", + return_value=["https://default.example.com"], + ): + result = resolve_frontend_url_from_cookie("ftp://bad-url.com") + assert result == "https://default.example.com/" + + +# ── GitHub OAuth エンドポイント統合テスト ──────────────────────────────── + + +def test_github_login_url_returns_state(client) -> None: + """login-url エンドポイントが authorization_url と state を JSON で返すことを確認する。""" + response = client.get( + "/auth/github/login-url", + headers={"Origin": "http://localhost:8788"}, + ) + + assert response.status_code == 200 + data = response.json() + assert "https://github.com/login/oauth/authorize" in data["authorization_url"] + # state はフロントの sessionStorage に保存して CSRF 検証する + assert isinstance(data["state"], str) + assert len(data["state"]) > 0 + # /auth/** rewrite を回避するため redirect_uri は /github/callback でなければならない + parsed = urlparse(data["authorization_url"]) + redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] + assert redirect_uri.endswith("/github/callback") + assert "/auth/github/callback" not in redirect_uri + + +def test_github_login_url_uses_frontend_origin_when_callback_base_url_unset(client) -> None: + response = client.get( + "/auth/github/login-url", + headers={ + "Origin": "http://localhost:8788", + "Host": "devforge-dev-XXXXX-an.a.run.app", + "X-Forwarded-Proto": "https", + }, + ) + + assert response.status_code == 200 + parsed = urlparse(response.json()["authorization_url"]) + redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] + assert redirect_uri == "http://localhost:8788/github/callback" + + +def test_github_login_url_uses_callback_base_url_when_set(client) -> None: + """CALLBACK_BASE_URL が設定されている場合、x-forwarded-host より優先されることを確認する。""" + with patch.dict(os.environ, {"CALLBACK_BASE_URL": "devforge-dev-XXXXX-an.a.run.app"}): + response = client.get( + "/auth/github/login-url", + headers={ + "Origin": "http://localhost:8788", + "Host": "devforge-dev-XXXXX-an.a.run.app", + "X-Forwarded-Proto": "https", + }, + ) + + assert response.status_code == 200 + parsed = urlparse(response.json()["authorization_url"]) + redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] + assert redirect_uri == "devforge-dev-XXXXX-an.a.run.app/github/callback" + + +def test_github_login_redirect_to_github(client) -> None: + """GET /auth/github/login が GitHub の認可 URL に 303 リダイレクトすることを確認する。""" + response = client.get( + "/auth/github/login", + params={"return_to": "http://localhost:8788/index.html"}, + headers={ + "Host": "devforge-dev-XXXXX-an.a.run.app", + "X-Forwarded-Proto": "https", + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert "https://github.com/login/oauth/authorize" in response.headers["location"] + parsed = urlparse(response.headers["location"]) + redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] + assert redirect_uri == "http://localhost:8788/github/callback" + + +def test_github_callback_redirect_returns_error_when_code_missing(client) -> None: + """GET /auth/github/callback は code が無い場合、トークン交換せずエラー付きでフロントへリダイレクトする。 + + state はフロントの sessionStorage で検証する設計のため、サーバー側では検証しない。 + """ + with patch("httpx.AsyncClient") as mock_async_client: + response = client.get( + "/auth/github/callback", + follow_redirects=False, + ) + + assert response.status_code == 200 + assert mock_async_client.call_count == 0 + # 200 + HTML リダイレクト: デフォルトフロント (CORS_ORIGINS[0]) に github_error 付きで戻る + assert "localhost:8788" in response.text + assert "github_error=" in response.text + + +def test_github_callback_redirect_sets_auth_cookie(client) -> None: + """GET /auth/github/callback は code を受け取って認証 Cookie を発行し、デフォルトフロントへリダイレクトする。 + + state 検証と redirect URL の cookie 読み出しはフロント (sessionStorage) に移譲したため、 + リダイレクト先はサーバー側の CORS_ORIGINS[0] にフォールバックする。 + """ + token_response = MagicMock() + token_response.json.return_value = {"access_token": "github-access-token"} + user_response = MagicMock() + user_response.json.return_value = {"id": 12345, "login": "octocat"} + + with patch("httpx.AsyncClient") as mock_cls: + mock_http = AsyncMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=False) + mock_http.post.return_value = token_response + mock_http.get.return_value = user_response + mock_cls.return_value = mock_http + + response = client.get( + "/auth/github/callback?code=test-code", + follow_redirects=False, + ) + + assert response.status_code == 200 + # 200 + HTML リダイレクト: フロントエンド URL が HTML 本文に含まれていることを確認する + assert "localhost:8788" in response.text + assert "access_token=" in response.headers["set-cookie"] + + +def test_github_callback_post_does_not_require_cookie(client) -> None: + """POST /auth/github/callback は Cookie の state を検証せずトークン交換に進むことを確認する。 + + state はフロントの sessionStorage で検証済みのためサーバー側では Cookie を見ない。 + """ + token_response = MagicMock() + token_response.json.return_value = {"access_token": "github-access-token"} + user_response = MagicMock() + user_response.json.return_value = {"id": 67890, "login": "octo-post"} + + client.cookies.clear() # Cookie が無くても通ることを確認する + + with patch("httpx.AsyncClient") as mock_cls: + mock_http = AsyncMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=False) + mock_http.post.return_value = token_response + mock_http.get.return_value = user_response + mock_cls.return_value = mock_http + + response = client.post( + "/auth/github/callback", + json={"code": "test-code", "state": "any-state-from-frontend"}, + headers={ + "Host": "devforge-dev-XXXXX-an.a.run.app", + "X-Forwarded-Proto": "https", + }, + ) + + assert response.status_code == 200 + assert response.json()["username"] == "github:octo-post" + assert "access_token=" in response.headers["set-cookie"] + # GitHub への redirect_uri も /github/callback でトークン交換していることを確認する + posted_kwargs = mock_http.post.call_args.kwargs + assert posted_kwargs["json"]["redirect_uri"].endswith("/github/callback") + assert "/auth/github/callback" not in posted_kwargs["json"]["redirect_uri"] + + +def test_github_callback_post_uses_frontend_origin_when_callback_base_url_unset(client) -> None: + """ローカル開発では frontend の Origin を redirect_uri の base に使うことを確認する。""" + token_response = MagicMock() + token_response.json.return_value = {"access_token": "github-access-token"} + user_response = MagicMock() + user_response.json.return_value = {"id": 24680, "login": "octo-local"} + + with patch("httpx.AsyncClient") as mock_cls: + mock_http = AsyncMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=False) + mock_http.post.return_value = token_response + mock_http.get.return_value = user_response + mock_cls.return_value = mock_http + + response = client.post( + "/auth/github/callback", + json={"code": "test-code", "state": "state-from-frontend"}, + headers={ + "Origin": "http://localhost:8788", + "Host": "localhost:8000", + }, + ) + + assert response.status_code == 200 + posted_kwargs = mock_http.post.call_args.kwargs + assert posted_kwargs["json"]["redirect_uri"] == "http://localhost:8788/github/callback" diff --git a/backend/tests/auth/test_token_manager.py b/backend/tests/auth/test_token_manager.py new file mode 100644 index 00000000..c0891248 --- /dev/null +++ b/backend/tests/auth/test_token_manager.py @@ -0,0 +1,151 @@ +"""token_manager に対応する単体テスト。 + +- RS256 アクセストークン / リフレッシュトークンの生成と検証 +- Cookie の Secure / SameSite デフォルト値(settings 側のヘルパ経由) +- ログアウト時の Cookie 削除属性(max-age=0) +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.core.security.auth import ( + create_access_token, + create_refresh_token, +) +from app.core.settings import get_cookie_samesite, get_cookie_secure +from jose import jwt + +from conftest import _test_public_key, auth_header + +# ── RS256 トークン生成・検証 ────────────────────────────────────── + + +def test_create_access_token_contains_username() -> None: + token = create_access_token("alice") + payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) + + assert payload["sub"] == "alice" + + +def test_create_access_token_has_expiry() -> None: + token = create_access_token("alice") + payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) + + assert "exp" in payload + + +def test_create_access_token_type_is_access() -> None: + token = create_access_token("alice") + payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) + + assert payload["type"] == "access" + + +def test_create_refresh_token_type_is_refresh() -> None: + token, jti = create_refresh_token("alice") + payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) + + assert payload["type"] == "refresh" + assert payload["sub"] == "alice" + assert payload["jti"] == jti + + +def test_hs256_token_rejected_by_rs256_verification() -> None: + """HS256 で署名したトークンが RS256 の検証で拒否されることを確認する。""" + hs256_token = jwt.encode({"sub": "alice", "type": "access"}, "secret", algorithm="HS256") + + with pytest.raises(Exception): + jwt.decode(hs256_token, _test_public_key, algorithms=["RS256"]) + + +# ── Cookie 設定(secure / samesite デフォルト) ─────────────────── + + +def test_cookie_secure_defaults_false_for_localhost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("COOKIE_SECURE", raising=False) + monkeypatch.setenv("CORS_ORIGINS", "http://localhost:8788") + + assert get_cookie_secure() is False + + +def test_cookie_secure_defaults_true_when_non_local_origin_exists( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("COOKIE_SECURE", raising=False) + monkeypatch.setenv( + "CORS_ORIGINS", + "http://localhost:8788,https://app.example.com", + ) + + assert get_cookie_secure() is True + + +def test_cookie_samesite_defaults_lax_for_localhost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("COOKIE_SAMESITE", raising=False) + monkeypatch.setenv("CORS_ORIGINS", "http://localhost:8788") + + assert get_cookie_samesite() == "lax" + + +def test_cookie_samesite_defaults_none_for_non_local_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("COOKIE_SAMESITE", raising=False) + monkeypatch.setenv("CORS_ORIGINS", "https://storage.googleapis.com") + + assert get_cookie_samesite() == "none" + + +# ── Cookie 属性検証(session Cookie の名称・HttpOnly・SameSite)────────────── + + +def test_github_callback_post_sets_session_cookie_with_httponly(client) -> None: + """POST /auth/github/callback が HttpOnly の session Cookie を設定することを確認する。""" + token_response = MagicMock() + token_response.json.return_value = {"access_token": "github-access-token"} + user_response = MagicMock() + user_response.json.return_value = {"id": 11111, "login": "cookie-test-user"} + + with patch("httpx.AsyncClient") as mock_cls: + mock_http = AsyncMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=False) + mock_http.post.return_value = token_response + mock_http.get.return_value = user_response + mock_cls.return_value = mock_http + + response = client.post( + "/auth/github/callback", + json={"code": "test-code", "state": "state-from-frontend"}, + headers={"Origin": "http://localhost:8788"}, + ) + + assert response.status_code == 200 + all_set_cookie = response.headers.get("set-cookie", "") + # session Cookie が設定されていること + assert "session=" in all_set_cookie + # HttpOnly 属性が付与されていること + assert "HttpOnly" in all_set_cookie + # SameSite 属性が付与されていること(ローカルでは lax) + assert "samesite=lax" in all_set_cookie.lower() + # Firebase 時代の __session Cookie 名が使われていないこと + assert "__session=" not in all_set_cookie + + +def test_logout_clears_session_cookie_with_max_age_zero(client) -> None: + """POST /auth/logout が session Cookie を Max-Age=0 で削除することを確認する。""" + auth_header(client, "logout-cookie-attr-user") + + response = client.post("/auth/logout") + + assert response.status_code == 204 + all_set_cookie = response.headers.get("set-cookie", "") + # session Cookie が削除されていること(max-age=0 で上書き) + assert "session=" in all_set_cookie + assert "max-age=0" in all_set_cookie.lower() + # Firebase 時代の __session Cookie 名が使われていないこと + assert "__session=" not in all_set_cookie diff --git a/backend/tests/blog/__init__.py b/backend/tests/blog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_blog.py b/backend/tests/blog/test_accounts.py similarity index 57% rename from backend/tests/test_blog.py rename to backend/tests/blog/test_accounts.py index d4b513ba..0df0cefc 100644 --- a/backend/tests/test_blog.py +++ b/backend/tests/blog/test_accounts.py @@ -1,8 +1,10 @@ +"""ブログ連携アカウント CRUD と記事一覧 API の統合テスト。""" + from datetime import datetime, timezone from unittest.mock import AsyncMock, patch import pytest -from app.models import BlogAccount, BlogSummaryCache +from app.models import BlogSummaryCache from app.repositories import BlogAccountRepository from fastapi.testclient import TestClient from sqlalchemy.orm.session import Session @@ -10,7 +12,7 @@ from conftest import auth_header # テスト中は外部 API 呼び出しをモックし、常にユーザーが存在する扱いにする -_VERIFY_PATCH = "app.routers.blog.verify_user_exists" +_VERIFY_PATCH = "app.routers.blog.accounts.verify_user_exists" def test_add_blog_account(client: TestClient) -> None: @@ -313,146 +315,6 @@ def test_list_blog_articles_filter_platform(client: TestClient) -> None: assert resp.json() == [] -def test_sync_requires_auth(client: TestClient) -> None: - """認証なしで sync → 401。""" - resp = client.post("/api/blog/accounts/dummy-id/sync") - assert resp.status_code == 401 - - -def test_sync_account_returns_404_when_user_not_found(client: TestClient) -> None: - """同期時の再検証で対象が見つからなければ 404 を返すこと。""" - headers = auth_header(client) - with patch(_VERIFY_PATCH, new_callable=AsyncMock, return_value=True): - resp = client.post( - "/api/blog/accounts", - json={ - "platform": "zenn", - "username": "testuser", - }, - headers=headers, - ) - account_id = resp.json()["id"] - - with patch( - "app.services.blog.sync_service.verify_user_exists", - new_callable=AsyncMock, - return_value=False, - ): - resp = client.post(f"/api/blog/accounts/{account_id}/sync", headers=headers) - - assert resp.status_code == 404 - body = resp.json() - assert body["message"] == "指定されたアカウントが見つかりません。ユーザー名を確認してください。" - assert body["code"] == "VALIDATION_ERROR" - - -def test_sync_account_normalizes_saved_username_and_removes_stale_articles( - client: TestClient, db_session: Session -) -> None: - """保存済み URL を正規化し、同期結果に含まれない古い記事を削除すること。""" - headers = auth_header(client) - - from app.repositories import BlogArticleRepository, UserRepository - - user = UserRepository(db_session).get_by_username("testuser") - account = BlogAccount( - user_id=user.id, - platform="zenn", - username="https://zenn.dev/testuser/articles/legacy-post", - ) - db_session.add(account) - db_session.commit() - db_session.refresh(account) - - repo = BlogArticleRepository(db_session, user.id) - repo.upsert_many( - [ - { - "account_id": account.id, - "platform": "zenn", - "external_id": "legacy-post", - "title": "古い記事", - "url": "https://zenn.dev/legacy/articles/legacy-post", - "published_at": "2026-03-01", - "likes_count": 10, - "summary": "", - "tags": ["Python"], - }, - ] - ) - - with ( - patch( - "app.services.blog.sync_service.verify_user_exists", - new_callable=AsyncMock, - return_value=True, - ), - patch( - "app.services.blog.sync_service.fetch_articles", - new_callable=AsyncMock, - return_value=[], - ) as mock_fetch_articles, - ): - resp = client.post(f"/api/blog/accounts/{account.id}/sync", headers=headers) - - assert resp.status_code == 200 - assert resp.json() == {"synced_count": 0, "total_count": 0} - mock_fetch_articles.assert_awaited_once_with("zenn", "testuser") - - refreshed_account = BlogAccountRepository(db_session, user.id).get_by_id(account.id) - assert refreshed_account is not None - assert refreshed_account.username == "testuser" - assert refreshed_account.last_synced_at is not None - assert repo.count_by_user() == 0 - - -def test_upsert_articles_no_duplicates(client: TestClient, db_session: Session) -> None: - """同じ記事を2回 upsert しても重複しない。""" - headers = auth_header(client) - - # アカウント作成 - with patch(_VERIFY_PATCH, new_callable=AsyncMock, return_value=True): - resp = client.post( - "/api/blog/accounts", - json={ - "platform": "zenn", - "username": "testuser", - }, - headers=headers, - ) - account_id = resp.json()["id"] - - # ユーザーIDを取得 - from app.repositories import BlogArticleRepository, UserRepository - - user = UserRepository(db_session).get_by_username("testuser") - repo = BlogArticleRepository(db_session, user.id) - - articles = [ - { - "account_id": account_id, - "platform": "zenn", - "external_id": "slug-1", - "title": "記事1", - "url": "https://zenn.dev/testuser/articles/slug-1", - "published_at": "2026-03-01", - "likes_count": 10, - "summary": "", - "tags": ["Python"], - }, - ] - - count1 = repo.upsert_many(articles) - assert count1 == 1 - - # 同じ記事を再度 upsert - count2 = repo.upsert_many(articles) - assert count2 == 0 - - # 合計1件のまま - assert repo.count_by_user() == 1 - - def test_list_blog_articles_returns_platform_and_tags( client: TestClient, db_session: Session ) -> None: @@ -494,109 +356,3 @@ def test_list_blog_articles_returns_platform_and_tags( data = resp.json() assert data[0]["platform"] == "zenn" assert data[0]["tags"] == ["Python", "FastAPI"] - - -def test_summarize_blog_returns_202_when_llm_available(client: TestClient) -> None: - """LLM 利用可能時は 202 で pending を返す。""" - headers = auth_header(client) - with patch("app.routers.blog.check_llm_available", new_callable=AsyncMock, return_value=True): - resp = client.post("/api/blog/summarize", headers=headers) - assert resp.status_code == 202 - data = resp.json() - assert data["status"] == "pending" - assert data["available"] is False - - -def test_summarize_blog_returns_unavailable_when_llm_not_available(client: TestClient) -> None: - """LLM 利用不可なら available=false を返す。""" - headers = auth_header(client) - with patch("app.routers.blog.check_llm_available", new_callable=AsyncMock, return_value=False): - resp = client.post("/api/blog/summarize", headers=headers) - assert resp.status_code == 202 - assert resp.json()["available"] is False - - -# ── キャッシュ TTL ───────────────────────────────────────────────────────── - - -def test_get_summary_cache_returns_unavailable_when_expired( - client: TestClient, db_session: Session -) -> None: - """expires_at が過去のキャッシュは無効と見なし available=false を返す。""" - from datetime import timedelta - - from app.repositories import UserRepository - - headers = auth_header(client) - user = UserRepository(db_session).get_by_username("testuser") - db_session.add( - BlogSummaryCache( - user_id=user.id, - summary="古い分析結果", - status="completed", - expires_at=datetime.now(timezone.utc) - timedelta(days=1), - ) - ) - db_session.commit() - - resp = client.get("/api/blog/summary-cache", headers=headers) - assert resp.status_code == 200 - data = resp.json() - assert data["available"] is False - assert data["summary"] == "" - - # レコードが削除されていること - assert db_session.query(BlogSummaryCache).filter_by(user_id=user.id).first() is None - - -def test_get_summary_cache_returns_data_when_not_expired( - client: TestClient, db_session: Session -) -> None: - """expires_at が未来のキャッシュは有効と見なし summary を返す。""" - from datetime import timedelta - - from app.repositories import UserRepository - - headers = auth_header(client) - user = UserRepository(db_session).get_by_username("testuser") - db_session.add( - BlogSummaryCache( - user_id=user.id, - summary="有効な分析結果", - status="completed", - expires_at=datetime.now(timezone.utc) + timedelta(days=7), - ) - ) - db_session.commit() - - resp = client.get("/api/blog/summary-cache", headers=headers) - assert resp.status_code == 200 - data = resp.json() - assert data["available"] is True - assert data["summary"] == "有効な分析結果" - - -def test_summarize_blog_allows_regenration_when_cache_expired( - client: TestClient, db_session: Session -) -> None: - """期限切れキャッシュは pending/processing 扱いにならず再生成を許可する。""" - from datetime import timedelta - - from app.repositories import UserRepository - - headers = auth_header(client) - user = UserRepository(db_session).get_by_username("testuser") - db_session.add( - BlogSummaryCache( - user_id=user.id, - summary="古い要約", - status="completed", - expires_at=datetime.now(timezone.utc) - timedelta(days=1), - ) - ) - db_session.commit() - - with patch("app.routers.blog.check_llm_available", new_callable=AsyncMock, return_value=True): - resp = client.post("/api/blog/summarize", headers=headers) - assert resp.status_code == 202 - assert resp.json()["status"] == "pending" diff --git a/backend/tests/blog/test_score.py b/backend/tests/blog/test_score.py new file mode 100644 index 00000000..517f7b24 --- /dev/null +++ b/backend/tests/blog/test_score.py @@ -0,0 +1,78 @@ +"""ブログスコアリング API (/api/blog/score) の境界値テスト。 + +scorer 本体のロジックは tests/test_blog_scorer.py で個別に検証する。 +ここでは router → scorer の結線が API 経由でも動くことと、 +記事 0 件の境界値が破綻しないことを固定化する。 +""" + +from app.models import BlogAccount +from app.repositories import BlogArticleRepository, UserRepository +from fastapi.testclient import TestClient +from sqlalchemy.orm.session import Session + +from conftest import auth_header + + +def test_get_blog_score_with_no_articles_returns_zero_counts( + client: TestClient, db_session: Session +) -> None: + """記事 0 件のユーザーで /api/blog/score が 200 を返し、各種統計が 0 になること。""" + headers = auth_header(client, "score-empty-user") + + resp = client.get("/api/blog/score", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["tech_article_count"] == 0 + assert data["total_article_count"] == 0 + assert data["avg_monthly_posts"] == 0.0 + assert data["avg_likes"] == 0.0 + + +def test_get_blog_score_aggregates_tech_articles( + client: TestClient, db_session: Session +) -> None: + """技術記事 1 件・非技術記事 1 件を保存し、tech_article_count と total_article_count が + それぞれ正しく分かれて返ること。""" + headers = auth_header(client, "score-mix-user") + + user = UserRepository(db_session).get_by_username("score-mix-user") + assert user is not None + + account = BlogAccount(user_id=user.id, platform="zenn", username="score-mix-user") + db_session.add(account) + db_session.commit() + db_session.refresh(account) + + repo = BlogArticleRepository(db_session, user.id) + repo.upsert_many( + [ + { + "account_id": account.id, + "platform": "zenn", + "external_id": "tech-1", + "title": "Python の話", + "url": "https://example.com/tech-1", + "published_at": "2026-03-01", + "likes_count": 10, + "summary": "", + "tags": ["Python"], + }, + { + "account_id": account.id, + "platform": "zenn", + "external_id": "diary-1", + "title": "旅行日記", + "url": "https://example.com/diary-1", + "published_at": "2026-03-02", + "likes_count": 5, + "summary": "", + "tags": ["旅行"], + }, + ] + ) + + resp = client.get("/api/blog/score", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total_article_count"] == 2 + assert data["tech_article_count"] == 1 diff --git a/backend/tests/blog/test_summarize.py b/backend/tests/blog/test_summarize.py new file mode 100644 index 00000000..0c806ee0 --- /dev/null +++ b/backend/tests/blog/test_summarize.py @@ -0,0 +1,107 @@ +"""ブログ AI サマリ生成・サマリキャッシュ API の統合テスト。""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +from app.models import BlogSummaryCache +from app.repositories import UserRepository +from fastapi.testclient import TestClient +from sqlalchemy.orm.session import Session + +from conftest import auth_header + +_LLM_AVAILABLE_PATCH = "app.routers.blog.summarize.check_llm_available" + + +def test_summarize_blog_returns_202_when_llm_available(client: TestClient) -> None: + """LLM 利用可能時は 202 で pending を返す。""" + headers = auth_header(client) + with patch(_LLM_AVAILABLE_PATCH, new_callable=AsyncMock, return_value=True): + resp = client.post("/api/blog/summarize", headers=headers) + assert resp.status_code == 202 + data = resp.json() + assert data["status"] == "pending" + assert data["available"] is False + + +def test_summarize_blog_returns_unavailable_when_llm_not_available(client: TestClient) -> None: + """LLM 利用不可なら available=false を返す。""" + headers = auth_header(client) + with patch(_LLM_AVAILABLE_PATCH, new_callable=AsyncMock, return_value=False): + resp = client.post("/api/blog/summarize", headers=headers) + assert resp.status_code == 202 + assert resp.json()["available"] is False + + +# ── キャッシュ TTL ───────────────────────────────────────────────────────── + + +def test_get_summary_cache_returns_unavailable_when_expired( + client: TestClient, db_session: Session +) -> None: + """expires_at が過去のキャッシュは無効と見なし available=false を返す。""" + headers = auth_header(client) + user = UserRepository(db_session).get_by_username("testuser") + db_session.add( + BlogSummaryCache( + user_id=user.id, + summary="古い分析結果", + status="completed", + expires_at=datetime.now(timezone.utc) - timedelta(days=1), + ) + ) + db_session.commit() + + resp = client.get("/api/blog/summary-cache", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["available"] is False + assert data["summary"] == "" + + # レコードが削除されていること + assert db_session.query(BlogSummaryCache).filter_by(user_id=user.id).first() is None + + +def test_get_summary_cache_returns_data_when_not_expired( + client: TestClient, db_session: Session +) -> None: + """expires_at が未来のキャッシュは有効と見なし summary を返す。""" + headers = auth_header(client) + user = UserRepository(db_session).get_by_username("testuser") + db_session.add( + BlogSummaryCache( + user_id=user.id, + summary="有効な分析結果", + status="completed", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + ) + db_session.commit() + + resp = client.get("/api/blog/summary-cache", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["available"] is True + assert data["summary"] == "有効な分析結果" + + +def test_summarize_blog_allows_regenration_when_cache_expired( + client: TestClient, db_session: Session +) -> None: + """期限切れキャッシュは pending/processing 扱いにならず再生成を許可する。""" + headers = auth_header(client) + user = UserRepository(db_session).get_by_username("testuser") + db_session.add( + BlogSummaryCache( + user_id=user.id, + summary="古い要約", + status="completed", + expires_at=datetime.now(timezone.utc) - timedelta(days=1), + ) + ) + db_session.commit() + + with patch(_LLM_AVAILABLE_PATCH, new_callable=AsyncMock, return_value=True): + resp = client.post("/api/blog/summarize", headers=headers) + assert resp.status_code == 202 + assert resp.json()["status"] == "pending" diff --git a/backend/tests/blog/test_sync.py b/backend/tests/blog/test_sync.py new file mode 100644 index 00000000..c56e29d3 --- /dev/null +++ b/backend/tests/blog/test_sync.py @@ -0,0 +1,152 @@ +"""ブログ手動同期 API の統合テスト。""" + +from unittest.mock import AsyncMock, patch + +from app.models import BlogAccount +from app.repositories import BlogAccountRepository +from fastapi.testclient import TestClient +from sqlalchemy.orm.session import Session + +from conftest import auth_header + +_VERIFY_PATCH = "app.routers.blog.accounts.verify_user_exists" + + +def test_sync_requires_auth(client: TestClient) -> None: + """認証なしで sync → 401。""" + resp = client.post("/api/blog/accounts/dummy-id/sync") + assert resp.status_code == 401 + + +def test_sync_account_returns_404_when_user_not_found(client: TestClient) -> None: + """同期時の再検証で対象が見つからなければ 404 を返すこと。""" + headers = auth_header(client) + with patch(_VERIFY_PATCH, new_callable=AsyncMock, return_value=True): + resp = client.post( + "/api/blog/accounts", + json={ + "platform": "zenn", + "username": "testuser", + }, + headers=headers, + ) + account_id = resp.json()["id"] + + with patch( + "app.services.blog.sync_service.verify_user_exists", + new_callable=AsyncMock, + return_value=False, + ): + resp = client.post(f"/api/blog/accounts/{account_id}/sync", headers=headers) + + assert resp.status_code == 404 + body = resp.json() + assert body["message"] == "指定されたアカウントが見つかりません。ユーザー名を確認してください。" + assert body["code"] == "VALIDATION_ERROR" + + +def test_sync_account_normalizes_saved_username_and_removes_stale_articles( + client: TestClient, db_session: Session +) -> None: + """保存済み URL を正規化し、同期結果に含まれない古い記事を削除すること。""" + headers = auth_header(client) + + from app.repositories import BlogArticleRepository, UserRepository + + user = UserRepository(db_session).get_by_username("testuser") + account = BlogAccount( + user_id=user.id, + platform="zenn", + username="https://zenn.dev/testuser/articles/legacy-post", + ) + db_session.add(account) + db_session.commit() + db_session.refresh(account) + + repo = BlogArticleRepository(db_session, user.id) + repo.upsert_many( + [ + { + "account_id": account.id, + "platform": "zenn", + "external_id": "legacy-post", + "title": "古い記事", + "url": "https://zenn.dev/legacy/articles/legacy-post", + "published_at": "2026-03-01", + "likes_count": 10, + "summary": "", + "tags": ["Python"], + }, + ] + ) + + with ( + patch( + "app.services.blog.sync_service.verify_user_exists", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "app.services.blog.sync_service.fetch_articles", + new_callable=AsyncMock, + return_value=[], + ) as mock_fetch_articles, + ): + resp = client.post(f"/api/blog/accounts/{account.id}/sync", headers=headers) + + assert resp.status_code == 200 + assert resp.json() == {"synced_count": 0, "total_count": 0} + mock_fetch_articles.assert_awaited_once_with("zenn", "testuser") + + refreshed_account = BlogAccountRepository(db_session, user.id).get_by_id(account.id) + assert refreshed_account is not None + assert refreshed_account.username == "testuser" + assert refreshed_account.last_synced_at is not None + assert repo.count_by_user() == 0 + + +def test_upsert_articles_no_duplicates(client: TestClient, db_session: Session) -> None: + """同じ記事を2回 upsert しても重複しない。""" + headers = auth_header(client) + + # アカウント作成 + with patch(_VERIFY_PATCH, new_callable=AsyncMock, return_value=True): + resp = client.post( + "/api/blog/accounts", + json={ + "platform": "zenn", + "username": "testuser", + }, + headers=headers, + ) + account_id = resp.json()["id"] + + # ユーザーIDを取得 + from app.repositories import BlogArticleRepository, UserRepository + + user = UserRepository(db_session).get_by_username("testuser") + repo = BlogArticleRepository(db_session, user.id) + + articles = [ + { + "account_id": account_id, + "platform": "zenn", + "external_id": "slug-1", + "title": "記事1", + "url": "https://zenn.dev/testuser/articles/slug-1", + "published_at": "2026-03-01", + "likes_count": 10, + "summary": "", + "tags": ["Python"], + }, + ] + + count1 = repo.upsert_many(articles) + assert count1 == 1 + + # 同じ記事を再度 upsert + count2 = repo.upsert_many(articles) + assert count2 == 0 + + # 合計1件のまま + assert repo.count_by_user() == 1 diff --git a/backend/tests/security/__init__.py b/backend/tests/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/security/_helpers.py b/backend/tests/security/_helpers.py new file mode 100644 index 00000000..db2df920 --- /dev/null +++ b/backend/tests/security/_helpers.py @@ -0,0 +1,50 @@ +"""tests/security パッケージ内で共有する定数とヘルパ。""" + +from __future__ import annotations + +from app.models import User +from app.repositories import UserRepository +from fastapi.testclient import TestClient +from sqlalchemy import func, select + +#: 攻撃者が試しがちな SQL インジェクションペイロード。 +SQLI_PAYLOADS: tuple[str, ...] = ( + "' OR '1'='1", + "'; DROP TABLE users;--", + '" OR 1=1 --', + "' UNION SELECT id FROM users --", + "admin'/*", +) + +#: 適当な UUID v4 形式の文字列。実在しない resume_id として使う。 +DUMMY_UUID = "00000000-0000-0000-0000-000000000001" + + +RESUME_PAYLOAD: dict = { + "full_name": "山田 太郎", + "career_summary": "キャリアサマリー", + "self_pr": "自己PR", + "experiences": [], + "qualifications": [], +} + + +def create_resume(client: TestClient, headers: dict[str, str]) -> str: + """resume を作成し、id を返す。""" + resp = client.post("/api/resumes", json=RESUME_PAYLOAD, headers=headers) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def count_rows(db, model) -> int: + """テーブルの行数を取得する。""" + return db.scalar(select(func.count()).select_from(model)) or 0 + + +def ensure_user(db, username: str) -> User: + """指定 username のユーザーを取得または作成する。auth_header に依存せず直挿しする用途。""" + repo = UserRepository(db) + user = repo.get_by_username(username) + if not user: + user = repo.create(username, hashed_password=None, email=f"{username}@example.com") + return user diff --git a/backend/tests/security/test_admin_authorization.py b/backend/tests/security/test_admin_authorization.py new file mode 100644 index 00000000..3618c82a --- /dev/null +++ b/backend/tests/security/test_admin_authorization.py @@ -0,0 +1,62 @@ +"""master-data の admin 認可と /internal/tasks の Cloud Tasks ヘッダ要求の検証。""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + + +class TestAdminTokenRequired: + """master-data の書込系は admin Bearer token を要求する。""" + + @pytest.mark.parametrize( + "method,path,body", + [ + ("post", "/api/master-data/qualification", {"name": "x", "sort_order": 0}), + ("put", "/api/master-data/qualification/anything", {"name": "x", "sort_order": 0}), + ("delete", "/api/master-data/qualification/anything", None), + ( + "post", + "/api/master-data/technology-stack", + {"category": "c", "name": "x", "sort_order": 0}, + ), + ( + "put", + "/api/master-data/technology-stack/anything", + {"category": "c", "name": "x", "sort_order": 0}, + ), + ("delete", "/api/master-data/technology-stack/anything", None), + ], + ) + def test_missing_authorization_returns_401( + self, client: TestClient, method: str, path: str, body: dict | None + ) -> None: + if body is None: + resp = getattr(client, method)(path) + else: + resp = getattr(client, method)(path, json=body) + assert resp.status_code == 401 + + def test_wrong_bearer_token_returns_403(self, client: TestClient) -> None: + resp = client.post( + "/api/master-data/qualification", + json={"name": "x", "sort_order": 0}, + headers={"Authorization": "Bearer wrong-token"}, + ) + assert resp.status_code == 403 + + +class TestInternalSecret: + """Cloud Tasks コールバックには X-CloudTasks-QueueName を要求する。""" + + def test_unknown_task_type_returns_400(self, client: TestClient) -> None: + resp = client.post("/internal/tasks/totally-unknown-type", json={}) + assert resp.status_code == 400 + + def test_missing_cloud_tasks_header_returns_403( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + """TASK_RUNNER=cloud_tasks では X-CloudTasks-QueueName が無いと 403。""" + monkeypatch.setenv("TASK_RUNNER", "cloud_tasks") + resp = client.post("/internal/tasks/blog_summarize", json={"user_id": "x"}) + assert resp.status_code == 403 diff --git a/backend/tests/security/test_boundary_values.py b/backend/tests/security/test_boundary_values.py new file mode 100644 index 00000000..6a63f78e --- /dev/null +++ b/backend/tests/security/test_boundary_values.py @@ -0,0 +1,162 @@ +"""境界値とパスパラメータ型違反の検証。空文字・上限超過・負数・型違反で 422/404 を返すか。""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from conftest import auth_header + +from ._helpers import RESUME_PAYLOAD + + +class TestBoundaryValues: + """空文字・上限超過・負数などで 422 を返すことを固定化する。""" + + def test_resume_full_name_empty_returns_422(self, client: TestClient) -> None: + headers = auth_header(client, "bound-resume-empty") + resp = client.post( + "/api/resumes", + json={**RESUME_PAYLOAD, "full_name": ""}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_resume_full_name_over_max_length_returns_422(self, client: TestClient) -> None: + headers = auth_header(client, "bound-resume-max") + resp = client.post( + "/api/resumes", + json={**RESUME_PAYLOAD, "full_name": "あ" * 121}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_resume_career_summary_over_max_length_returns_422( + self, client: TestClient + ) -> None: + headers = auth_header(client, "bound-resume-cs") + resp = client.post( + "/api/resumes", + json={**RESUME_PAYLOAD, "career_summary": "x" * 2001}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_resume_team_member_count_negative_returns_422(self, client: TestClient) -> None: + """team.members[].count は ge=0 制約。負数は 422。""" + headers = auth_header(client, "bound-resume-team-neg") + payload = { + **RESUME_PAYLOAD, + "experiences": [ + { + "company": "X", + "business_description": "Y", + "start_date": "2024-01", + "end_date": "2024-12", + "is_current": False, + "clients": [ + { + "name": "", + "projects": [ + { + "name": "p", + "team": { + "total": "5", + "members": [{"role": "SE", "count": -1}], + }, + } + ], + } + ], + } + ], + } + resp = client.post("/api/resumes", json=payload, headers=headers) + assert resp.status_code == 422 + + def test_career_analysis_target_position_over_max_length_returns_422( + self, client: TestClient + ) -> None: + headers = auth_header(client, "bound-career-max") + resp = client.post( + "/api/career-analysis/generate", + json={"target_position": "x" * 201}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_blog_account_username_empty_returns_422(self, client: TestClient) -> None: + headers = auth_header(client, "bound-blog-empty") + resp = client.post( + "/api/blog/accounts", + json={"platform": "zenn", "username": ""}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_blog_account_username_over_max_length_returns_422( + self, client: TestClient + ) -> None: + headers = auth_header(client, "bound-blog-max") + resp = client.post( + "/api/blog/accounts", + json={"platform": "zenn", "username": "x" * 121}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_blog_account_unsupported_platform_returns_422(self, client: TestClient) -> None: + """Literal["zenn", "note", "qiita"] 以外は Pydantic Literal で 422。""" + headers = auth_header(client, "bound-blog-platform") + resp = client.post( + "/api/blog/accounts", + json={"platform": "unknown", "username": "x"}, + headers=headers, + ) + assert resp.status_code == 422 + + def test_master_data_name_empty_returns_422(self, client: TestClient) -> None: + resp = client.post( + "/api/master-data/qualification", + json={"name": "", "sort_order": 0}, + headers={"Authorization": "Bearer test-admin-token"}, + ) + assert resp.status_code == 422 + + def test_master_data_name_over_max_length_returns_422(self, client: TestClient) -> None: + resp = client.post( + "/api/master-data/qualification", + json={"name": "x" * 201, "sort_order": 0}, + headers={"Authorization": "Bearer test-admin-token"}, + ) + assert resp.status_code == 422 + + +class TestPathParamInjection: + """UUID / int 期待箇所への型違反で 422、整数だが範囲外なら 404 を固定化する。""" + + def test_resume_invalid_uuid_returns_422(self, client: TestClient) -> None: + headers = auth_header(client, "path-resume") + resp = client.get("/api/resumes/not-a-uuid", headers=headers) + assert resp.status_code == 422 + + def test_resume_uuid_invalid_format_returns_422(self, client: TestClient) -> None: + """UUID 形式に合わない文字列は 422 で弾かれ、DB に到達しない。""" + headers = auth_header(client, "path-resume-bad") + resp = client.get("/api/resumes/etc-passwd", headers=headers) + assert resp.status_code == 422 + + def test_career_analysis_alpha_id_returns_422(self, client: TestClient) -> None: + headers = auth_header(client, "path-career-alpha") + resp = client.get("/api/career-analysis/abc", headers=headers) + assert resp.status_code == 422 + + def test_career_analysis_float_id_returns_422(self, client: TestClient) -> None: + headers = auth_header(client, "path-career-float") + resp = client.delete("/api/career-analysis/1.5", headers=headers) + assert resp.status_code == 422 + + def test_career_analysis_negative_id_returns_404(self, client: TestClient) -> None: + """負数 ID は int としてパースされるが DB ヒットしないため 404。""" + headers = auth_header(client, "path-career-neg") + resp = client.get("/api/career-analysis/-1", headers=headers) + assert resp.status_code == 404 diff --git a/backend/tests/security/test_idor.py b/backend/tests/security/test_idor.py new file mode 100644 index 00000000..36e5c727 --- /dev/null +++ b/backend/tests/security/test_idor.py @@ -0,0 +1,228 @@ +"""IDOR (Insecure Direct Object References) 検証。 +user A のリソースが user B からは見えない・操作できないことを固定化する。 +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from app.models import ( + BlogAccount, + BlogSummaryCache, + CareerAnalysis, + GitHubAnalysisCache, + Notification, + Resume, +) +from app.repositories import UserRepository +from fastapi.testclient import TestClient +from sqlalchemy import select + +from conftest import auth_header + +from ._helpers import RESUME_PAYLOAD, create_resume, ensure_user + + +class TestIDOR: + """user A のリソースは user B からは見えない・操作できないことを固定化する。""" + + def test_resume_get_by_id_returns_404_for_other_user(self, client: TestClient) -> None: + headers_a = auth_header(client, "idor-resume-a") + a_id = create_resume(client, headers_a) + headers_b = auth_header(client, "idor-resume-b") + resp = client.get(f"/api/resumes/{a_id}", headers=headers_b) + assert resp.status_code == 404 + + def test_resume_put_does_not_modify_other_user_data( + self, client: TestClient, db_session + ) -> None: + headers_a = auth_header(client, "idor-resume-put-a") + a_id = create_resume(client, headers_a) + headers_b = auth_header(client, "idor-resume-put-b") + resp = client.put( + f"/api/resumes/{a_id}", + json={**RESUME_PAYLOAD, "full_name": "侵入者"}, + headers=headers_b, + ) + assert resp.status_code == 404 + # A の full_name が書き換わっていないこと + user_a = UserRepository(db_session).get_by_username("idor-resume-put-a") + assert user_a is not None + a_resume = db_session.scalar(select(Resume).where(Resume.user_id == user_a.id)) + assert a_resume is not None + assert a_resume.full_name == RESUME_PAYLOAD["full_name"] + + def test_resume_download_endpoints_reject_other_user(self, client: TestClient) -> None: + headers_a = auth_header(client, "idor-resume-dl-a") + a_id = create_resume(client, headers_a) + headers_b = auth_header(client, "idor-resume-dl-b") + for suffix in ("pdf", "markdown"): + resp = client.get(f"/api/resumes/{a_id}/{suffix}", headers=headers_b) + assert resp.status_code == 404, f"{suffix} should reject other-user access" + + def test_resume_delete_does_not_touch_other_user_data( + self, client: TestClient, db_session + ) -> None: + """B が DELETE しても A の resume は残り、B 自身は 404。""" + headers_a = auth_header(client, "idor-resume-del-a") + create_resume(client, headers_a) + headers_b = auth_header(client, "idor-resume-del-b") + resp = client.delete("/api/resumes", headers=headers_b) + assert resp.status_code == 404 + user_a = UserRepository(db_session).get_by_username("idor-resume-del-a") + assert user_a is not None + remaining = db_session.scalar(select(Resume).where(Resume.user_id == user_a.id)) + assert remaining is not None + + def test_career_analysis_status_returns_404_for_other_user( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-career-status-a") + analysis = self._insert_career_analysis(db_session, user_a.id) + headers_b = auth_header(client, "idor-career-status-b") + resp = client.get(f"/api/career-analysis/{analysis.id}/status", headers=headers_b) + assert resp.status_code == 404 + + def test_career_analysis_delete_does_not_touch_other_user_data( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-career-del-a") + analysis = self._insert_career_analysis(db_session, user_a.id) + analysis_id = analysis.id + headers_b = auth_header(client, "idor-career-del-b") + resp = client.delete(f"/api/career-analysis/{analysis_id}", headers=headers_b) + assert resp.status_code == 404 + remaining = db_session.scalar( + select(CareerAnalysis).where(CareerAnalysis.id == analysis_id) + ) + assert remaining is not None + + def test_career_analysis_retry_returns_404_for_other_user( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-career-retry-a") + analysis = self._insert_career_analysis(db_session, user_a.id, status="dead_letter") + headers_b = auth_header(client, "idor-career-retry-b") + resp = client.post(f"/api/career-analysis/{analysis.id}/retry", headers=headers_b) + assert resp.status_code == 404 + + def test_blog_account_delete_does_not_touch_other_user_data( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-blog-del-a") + account = BlogAccount(user_id=user_a.id, platform="zenn", username="user-a") + db_session.add(account) + db_session.commit() + account_id = account.id + headers_b = auth_header(client, "idor-blog-del-b") + resp = client.delete(f"/api/blog/accounts/{account_id}", headers=headers_b) + assert resp.status_code == 404 + remaining = db_session.scalar(select(BlogAccount).where(BlogAccount.id == account_id)) + assert remaining is not None + + def test_blog_account_patch_does_not_touch_other_user_data( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-blog-patch-a") + account = BlogAccount(user_id=user_a.id, platform="qiita", username="user-a") + db_session.add(account) + db_session.commit() + headers_b = auth_header(client, "idor-blog-patch-b") + # 早期 404 のため verify_user_exists には到達しないが、念のためモック + with patch( + "app.routers.blog.accounts.verify_user_exists", + new_callable=AsyncMock, + return_value=True, + ): + resp = client.patch( + "/api/blog/accounts/qiita", + json={"username": "intruder"}, + headers=headers_b, + ) + assert resp.status_code == 404 + unchanged = db_session.scalar( + select(BlogAccount).where( + BlogAccount.user_id == user_a.id, BlogAccount.platform == "qiita" + ) + ) + assert unchanged.username == "user-a" + + def test_blog_account_sync_returns_404_for_other_user( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-blog-sync-a") + account = BlogAccount(user_id=user_a.id, platform="zenn", username="user-a") + db_session.add(account) + db_session.commit() + account_id = account.id + headers_b = auth_header(client, "idor-blog-sync-b") + resp = client.post(f"/api/blog/accounts/{account_id}/sync", headers=headers_b) + assert resp.status_code == 404 + + def test_blog_summary_cache_does_not_leak_to_other_user( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-blog-cache-a") + cache_a = BlogSummaryCache( + user_id=user_a.id, summary="A の機密サマリ", status="completed" + ) + db_session.add(cache_a) + db_session.commit() + headers_b = auth_header(client, "idor-blog-cache-b") + resp = client.get("/api/blog/summary-cache", headers=headers_b) + assert resp.status_code == 200 + body = resp.json() + assert body["available"] is False + assert "A の機密サマリ" not in (body.get("summary") or "") + + def test_intelligence_cache_does_not_leak_to_other_user( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-intel-cache-a") + cache_a = GitHubAnalysisCache( + user_id=user_a.id, + analysis_result={"secret": "A だけが見るべきデータ"}, + status="completed", + ) + db_session.add(cache_a) + db_session.commit() + headers_b = auth_header(client, "idor-intel-cache-b") + resp = client.get("/api/intelligence/cache", headers=headers_b) + assert resp.status_code == 200 + body = resp.json() + assert body.get("analysis_result") is None + + def test_notification_mark_read_does_not_touch_other_user( + self, client: TestClient, db_session + ) -> None: + user_a = ensure_user(db_session, "idor-notif-a") + notification = Notification( + user_id=user_a.id, + task_type="github_analysis", + status="completed", + title="A 宛通知", + is_read=False, + ) + db_session.add(notification) + db_session.commit() + notif_id = notification.id + headers_b = auth_header(client, "idor-notif-b") + resp = client.patch(f"/api/notifications/{notif_id}/read", headers=headers_b) + assert resp.status_code == 404 + unchanged = db_session.scalar(select(Notification).where(Notification.id == notif_id)) + assert unchanged.is_read is False + + @staticmethod + def _insert_career_analysis( + db, user_id: str, *, status: str = "pending" + ) -> CareerAnalysis: + analysis = CareerAnalysis( + user_id=user_id, + version=1, + target_position="Backend", + status=status, + ) + db.add(analysis) + db.commit() + db.refresh(analysis) + return analysis diff --git a/backend/tests/security/test_no_auth.py b/backend/tests/security/test_no_auth.py new file mode 100644 index 00000000..497ca3c7 --- /dev/null +++ b/backend/tests/security/test_no_auth.py @@ -0,0 +1,66 @@ +"""認証なしでのアクセス検証。Cookie / CSRF なしで保護対象を叩いて 401/403 を返すかを固定化する。""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from ._helpers import DUMMY_UUID + +_PROTECTED_ENDPOINTS: list[tuple[str, str]] = [ + ("post", "/api/resumes"), + ("get", "/api/resumes/latest"), + ("get", f"/api/resumes/{DUMMY_UUID}"), + ("put", f"/api/resumes/{DUMMY_UUID}"), + ("delete", "/api/resumes"), + ("get", f"/api/resumes/{DUMMY_UUID}/pdf"), + ("get", f"/api/resumes/{DUMMY_UUID}/markdown"), + ("post", "/api/career-analysis/generate"), + ("get", "/api/career-analysis/"), + ("get", "/api/career-analysis/1"), + ("get", "/api/career-analysis/1/status"), + ("post", "/api/career-analysis/1/retry"), + ("delete", "/api/career-analysis/1"), + ("get", "/api/blog/accounts"), + ("post", "/api/blog/accounts"), + ("patch", "/api/blog/accounts/zenn"), + ("delete", "/api/blog/accounts/x"), + ("get", "/api/blog/articles"), + ("post", "/api/blog/accounts/x/sync"), + ("get", "/api/blog/summary-cache"), + ("get", "/api/blog/summary-cache/status"), + ("post", "/api/blog/summarize"), + ("post", "/api/blog/summarize/retry"), + ("get", "/api/blog/score"), + ("get", "/api/intelligence/cache"), + ("get", "/api/intelligence/cache/status"), + ("get", "/api/intelligence/progress"), + ("post", "/api/intelligence/analyze"), + ("post", "/api/intelligence/analyze/retry"), + ("post", "/api/intelligence/position-advice"), + ("get", "/api/notifications"), + ("get", "/api/notifications/unread-count"), + ("patch", "/api/notifications/abc/read"), + ("post", "/api/notifications/read-all"), +] + + +class TestNoAuthAccess: + """Cookie / CSRF なしで保護対象を叩くと 401/403 が返ることを固定化する。""" + + @pytest.mark.parametrize("method,path", _PROTECTED_ENDPOINTS) + def test_protected_endpoint_rejects_unauthenticated( + self, client: TestClient, method: str, path: str + ) -> None: + resp = getattr(client, method)(path) + assert resp.status_code in (401, 403), ( + f"{method.upper()} {path} の status は 401/403 期待だが {resp.status_code}: {resp.text}" + ) + + def test_health_endpoint_is_public(self, client: TestClient) -> None: + assert client.get("/health").status_code == 200 + + def test_master_data_list_is_public(self, client: TestClient) -> None: + """マスタの GET は公開(フロントが認証前に取得する設計)。""" + assert client.get("/api/master-data/qualification").status_code == 200 + assert client.get("/api/master-data/technology-stack").status_code == 200 diff --git a/backend/tests/security/test_sql_injection.py b/backend/tests/security/test_sql_injection.py new file mode 100644 index 00000000..61334b31 --- /dev/null +++ b/backend/tests/security/test_sql_injection.py @@ -0,0 +1,105 @@ +"""SQL インジェクション検証。 +SQLi メタ文字を流しても 500 にならず・他レコードを壊さず・他ユーザーデータを返さない。 +""" + +from __future__ import annotations + +import pytest +from app.models import User +from fastapi.testclient import TestClient + +from conftest import auth_header + +from ._helpers import RESUME_PAYLOAD, SQLI_PAYLOADS, count_rows + + +class TestSQLInjection: + """SQLi メタ文字を流しても 500 にならず・他レコードを壊さず・他ユーザーデータを返さない。""" + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_resume_full_name_treats_payload_as_literal( + self, client: TestClient, db_session, payload: str + ) -> None: + headers = auth_header(client, "sqli-resume") + resp = client.post( + "/api/resumes", + json={**RESUME_PAYLOAD, "full_name": payload}, + headers=headers, + ) + assert resp.status_code == 201 + latest = client.get("/api/resumes/latest", headers=headers) + assert latest.status_code == 200 + # ペイロードがリテラルとしてそのまま読み出せること + assert latest.json()["full_name"] == payload + # users テーブルが破壊されていないこと(少なくとも 1 行は残る) + assert count_rows(db_session, User) >= 1 + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_resume_path_param_rejects_sqli( + self, client: TestClient, payload: str + ) -> None: + """resume_id は UUID 型なので SQLi 文字列は DB に到達しない。 + UUID 型違反は 422、URL に ``/`` を含むペイロードはルートが一致せず 404 になるが、 + いずれも「攻撃文字列が DB クエリに乗らない」ことを示すので両方を許容する。 + """ + headers = auth_header(client, "sqli-resume-path") + resp = client.get(f"/api/resumes/{payload}", headers=headers) + assert resp.status_code in (404, 422), resp.text + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_career_analysis_path_param_rejects_sqli( + self, client: TestClient, payload: str + ) -> None: + """analysis_id は int 型。SQLi 文字列は 422、``/`` を含むペイロードは 404。""" + headers = auth_header(client, "sqli-career-path") + resp = client.get(f"/api/career-analysis/{payload}", headers=headers) + assert resp.status_code in (404, 422), resp.text + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_blog_account_path_returns_404_for_sqli( + self, client: TestClient, db_session, payload: str + ) -> None: + """blog account_id は str 型。SQLAlchemy がパラメタライズして 404。""" + headers = auth_header(client, "sqli-blog-account") + resp = client.delete(f"/api/blog/accounts/{payload}", headers=headers) + assert resp.status_code != 500, resp.text + assert resp.status_code in (404, 422) + # users テーブルが破壊されていないこと + assert count_rows(db_session, User) >= 1 + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_blog_articles_query_filter_handles_sqli( + self, client: TestClient, payload: str + ) -> None: + """?platform= に SQLi を渡しても 200 + 空配列。""" + headers = auth_header(client, "sqli-blog-articles") + resp = client.get("/api/blog/articles", params={"platform": payload}, headers=headers) + assert resp.status_code == 200 + assert resp.json() == [] + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_notification_path_returns_404_for_sqli( + self, client: TestClient, payload: str + ) -> None: + headers = auth_header(client, "sqli-notif") + resp = client.patch(f"/api/notifications/{payload}/read", headers=headers) + assert resp.status_code != 500, resp.text + assert resp.status_code in (404, 422) + + @pytest.mark.parametrize("payload", SQLI_PAYLOADS) + def test_master_data_name_treats_payload_as_literal( + self, client: TestClient, payload: str + ) -> None: + """admin token 経由で SQLi を保存しても、文字列リテラル扱いで他テーブルが壊れない。""" + resp = client.post( + "/api/master-data/qualification", + json={"name": payload, "sort_order": 0}, + headers={"Authorization": "Bearer test-admin-token"}, + ) + assert resp.status_code == 201 + assert resp.json()["name"] == payload + # 一覧 API が落ちずに 1 件返ること(DROP TABLE が解釈されていない証拠) + listed = client.get("/api/master-data/qualification") + assert listed.status_code == 200 + names = [item["name"] for item in listed.json()] + assert payload in names diff --git a/backend/tests/services/tasks/test_handlers_success.py b/backend/tests/services/tasks/test_handlers_success.py new file mode 100644 index 00000000..afd9baa2 --- /dev/null +++ b/backend/tests/services/tasks/test_handlers_success.py @@ -0,0 +1,129 @@ +""" +タスクハンドラの正常系パスを worker シムを介さず直接固定化するテスト。 + +`tests/test_worker/` 配下では `_run_*` シム経由のテストが網羅されている。 +こちらは「ハンドラ実装を直接呼んでも同じく status=completed に遷移すること」を +最小限のスモークテストで守る。 +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from app.models import BlogSummaryCache +from app.models.career_analysis import CareerAnalysis +from app.repositories import UserRepository +from app.services.tasks.handlers.blog_summarize import BlogSummarizeHandler +from app.services.tasks.handlers.career_analysis import CareerAnalysisHandler +from sqlalchemy.orm import Session + + +def _run(coro): + """async 関数を同期的に実行するヘルパー。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _make_user(db: Session, username: str): + return UserRepository(db).create( + username, hashed_password=None, email=f"{username}@test.com", + ) + + +class TestBlogSummarizeHandlerSuccess: + """BlogSummarizeHandler.run を直接呼び出す正常系。""" + + def test_run_completes_and_persists_summary(self, db_session: Session) -> None: + user = _make_user(db_session, "handler-blog-success") + cache = BlogSummaryCache(user_id=user.id, status="pending") + db_session.add(cache) + db_session.commit() + + # 記事 1 件を持つモック repo + art = MagicMock() + art.title = "テスト記事" + art.url = "https://example.com/a" + art.published_at = "2024-01-01" + art.likes_count = 1 + art.summary = "本文" + art.tags = ["python"] + art.platform = "zenn" + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [art] + + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=True) + + with ( + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=mock_repo, + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + patch( + "app.services.intelligence.llm_summarizer.summarize_blog_articles", + new_callable=AsyncMock, + return_value="完成版サマリ", + ), + ): + handler = BlogSummarizeHandler() + _run(handler.run(db_session, {"user_id": user.id})) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.summary == "完成版サマリ" + assert cache.completed_at is not None + assert cache.expires_at is not None + + +class TestCareerAnalysisHandlerSuccess: + """CareerAnalysisHandler.run を直接呼び出す正常系。""" + + def test_run_completes_and_persists_result(self, db_session: Session) -> None: + user = _make_user(db_session, "handler-career-success") + analysis = CareerAnalysis( + user_id=user.id, + version=1, + target_position="Backend", + status="pending", + ) + db_session.add(analysis) + db_session.commit() + + mock_llm = MagicMock() + fake_result = { + "growth_summary": "", + "tech_stack": {"top": [], "summary": ""}, + "strengths": [], + "career_paths": [], + "action_items": [], + } + + with ( + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + patch( + "app.services.career_analysis.builder.build_career_analysis", + new_callable=AsyncMock, + return_value=fake_result, + ), + ): + handler = CareerAnalysisHandler() + _run( + handler.run( + db_session, + { + "user_id": user.id, + "record_id": analysis.id, + "target_position": "Backend", + }, + ) + ) + + db_session.refresh(analysis) + assert analysis.status == "completed" + assert analysis.result_json is not None + assert analysis.completed_at is not None diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index f65a466f..00000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,576 +0,0 @@ -import json -import logging -import os -from unittest.mock import AsyncMock, MagicMock, patch -from urllib.parse import parse_qs, urlparse - -import pytest -from app.core.security.auth import ( - create_access_token, - create_refresh_token, -) -from app.core.settings import get_cookie_samesite, get_cookie_secure -from app.main import limiter -from jose import jwt - -from conftest import _test_public_key, auth_header - -# ── RS256 トークン生成・検証 ────────────────────────────────────── - - -def test_create_access_token_contains_username() -> None: - token = create_access_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert payload["sub"] == "alice" - - -def test_create_access_token_has_expiry() -> None: - token = create_access_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert "exp" in payload - - -def test_create_access_token_type_is_access() -> None: - token = create_access_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert payload["type"] == "access" - - -def test_create_refresh_token_type_is_refresh() -> None: - token, jti = create_refresh_token("alice") - payload = jwt.decode(token, _test_public_key, algorithms=["RS256"]) - - assert payload["type"] == "refresh" - assert payload["sub"] == "alice" - assert payload["jti"] == jti - - -def test_hs256_token_rejected_by_rs256_verification() -> None: - """HS256 で署名したトークンが RS256 の検証で拒否されることを確認する。""" - hs256_token = jwt.encode({"sub": "alice", "type": "access"}, "secret", algorithm="HS256") - - with pytest.raises(Exception): - jwt.decode(hs256_token, _test_public_key, algorithms=["RS256"]) - - -def test_access_token_cannot_be_used_as_refresh(client) -> None: - """アクセストークンでリフレッシュエンドポイントを叩いて拒否されることを確認する。""" - auth_header(client, "rftest") - # session から access_token を取り出し、refresh_token として上書きする - import json as _json - - raw = client.cookies.get("session", "{}") - data = _json.loads(raw) - data["refresh_token"] = data.get("access_token", "") - client.cookies.set("session", _json.dumps(data)) - - response = client.post("/auth/refresh") - - assert response.status_code == 401 - - -def test_refresh_token_cannot_access_api(client) -> None: - """リフレッシュトークンで通常 API を叩いて拒否されることを確認する。""" - import json as _json - - auth_header(client, "apitest") - # session の access_token をリフレッシュトークンで上書きする - refresh_token, _ = create_refresh_token("apitest") - raw = client.cookies.get("session", "{}") - data = _json.loads(raw) - data["access_token"] = refresh_token - client.cookies.set("session", _json.dumps(data)) - - response = client.get("/auth/me") - - assert response.status_code == 401 - - -def test_refresh_issues_new_access_token(client) -> None: - """有効なリフレッシュトークンで新しいアクセストークンが発行されることを確認する。""" - auth_header(client, "refuser") - - response = client.post("/auth/refresh") - - assert response.status_code == 200 - assert response.json()["username"] == "refuser" - assert "access_token=" in response.headers.get("set-cookie", "") - - -# ── ログアウト ──────────────────────────────────────────────────── - - -def test_logout_clears_cookies(client) -> None: - """ログアウト後に認証 Cookie が削除されることを確認する。""" - auth_header(client, "logoutuser") - - response = client.post("/auth/logout") - - assert response.status_code == 204 - set_cookie = response.headers.get("set-cookie", "") - assert "access_token=" in set_cookie - - -def test_logout_invalidates_refresh_token(client) -> None: - """ログアウト後にリフレッシュトークンで再認証できないことを確認する。""" - auth_header(client, "logoutuser2") - - client.post("/auth/logout") - response = client.post("/auth/refresh") - - assert response.status_code == 401 - - -def test_refresh_rejects_revoked_jti(client) -> None: - """DB の refresh_jti と一致しないトークンでリフレッシュが拒否されることを確認する。""" - import json as _json - - auth_header(client, "revokeduser") - # jti が DB と一致しない別トークンを発行し、session の refresh_token を上書きする - stale_token, _ = create_refresh_token("revokeduser") - raw = client.cookies.get("session", "{}") - data = _json.loads(raw) - data["refresh_token"] = stale_token - client.cookies.set("session", _json.dumps(data)) - - response = client.post("/auth/refresh") - - assert response.status_code == 401 - - -def test_logout_without_token_returns_204(client) -> None: - """リフレッシュトークンなしでもログアウトが 204 を返すことを確認する。""" - response = client.post("/auth/logout") - - assert response.status_code == 204 - - -# ── Cookie 設定 ─────────────────────────────────────────────────── - - -def test_cookie_secure_defaults_false_for_localhost( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("COOKIE_SECURE", raising=False) - monkeypatch.setenv("CORS_ORIGINS", "http://localhost:8788") - - assert get_cookie_secure() is False - - -def test_cookie_secure_defaults_true_when_non_local_origin_exists( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("COOKIE_SECURE", raising=False) - monkeypatch.setenv( - "CORS_ORIGINS", - "http://localhost:8788,https://app.example.com", - ) - - assert get_cookie_secure() is True - - -def test_cookie_samesite_defaults_lax_for_localhost( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("COOKIE_SAMESITE", raising=False) - monkeypatch.setenv("CORS_ORIGINS", "http://localhost:8788") - - assert get_cookie_samesite() == "lax" - - -def test_cookie_samesite_defaults_none_for_non_local_origin( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("COOKIE_SAMESITE", raising=False) - monkeypatch.setenv("CORS_ORIGINS", "https://storage.googleapis.com") - - assert get_cookie_samesite() == "none" - - -# ── /auth/me ───────────────────────────────────────────────────── - - -def test_me_returns_current_user(client) -> None: - auth_header(client, "alice") - - response = client.get("/auth/me") - - assert response.status_code == 200 - assert response.json() == { - "username": "alice", - "is_github_user": False, - } - - -# ── CSRF ───────────────────────────────────────────────────────── - - -def _csrf_resume_payload() -> dict: - return { - "full_name": "テスト", - "career_summary": "要約", - "self_pr": "自己PR", - "experiences": [], - "qualifications": [], - } - - -def test_post_without_csrf_token_is_rejected(client) -> None: - """CSRFトークンなしの POST リクエストが 403 で拒否されることを確認する。""" - auth_header(client, "csrftest1") - client.cookies.delete("csrf_token") - - response = client.post( - "/api/resumes", - json=_csrf_resume_payload(), - # CSRF ヘッダーなし - ) - - assert response.status_code == 403 - - -def test_post_with_invalid_csrf_token_is_rejected(client) -> None: - """不正な CSRFトークンの POST リクエストが 403 で拒否されることを確認する。""" - auth_header(client, "csrftest2") - - response = client.post( - "/api/resumes", - json=_csrf_resume_payload(), - headers={"X-CSRF-Token": "invalid-token"}, - ) - - assert response.status_code == 403 - - -def test_post_with_valid_csrf_token_succeeds(client) -> None: - """正しい CSRF トークンの POST リクエストが成功することを確認する。""" - headers = auth_header(client, "csrftest3") - - response = client.post( - "/api/resumes", - json=_csrf_resume_payload(), - headers=headers, - ) - - assert response.status_code == 201 - - -def test_get_request_skips_csrf_check(client) -> None: - """GET リクエストは CSRF チェックをスキップすることを確認する。""" - auth_header(client, "csrftest4") - - response = client.get("/auth/me") - - assert response.status_code == 200 - - -# ── GitHub OAuth ────────────────────────────────────────────────── - - -def test_github_login_url_returns_state(client) -> None: - """login-url エンドポイントが authorization_url と state を JSON で返すことを確認する。""" - response = client.get( - "/auth/github/login-url", - headers={"Origin": "http://localhost:8788"}, - ) - - assert response.status_code == 200 - data = response.json() - assert "https://github.com/login/oauth/authorize" in data["authorization_url"] - # state はフロントの sessionStorage に保存して CSRF 検証する - assert isinstance(data["state"], str) - assert len(data["state"]) > 0 - # /auth/** rewrite を回避するため redirect_uri は /github/callback でなければならない - parsed = urlparse(data["authorization_url"]) - redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] - assert redirect_uri.endswith("/github/callback") - assert "/auth/github/callback" not in redirect_uri - - -def test_github_login_url_uses_frontend_origin_when_callback_base_url_unset(client) -> None: - response = client.get( - "/auth/github/login-url", - headers={ - "Origin": "http://localhost:8788", - "Host": "devforge-dev-XXXXX-an.a.run.app", - "X-Forwarded-Proto": "https", - }, - ) - - assert response.status_code == 200 - parsed = urlparse(response.json()["authorization_url"]) - redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] - assert redirect_uri == "http://localhost:8788/github/callback" - - -def test_github_login_url_uses_callback_base_url_when_set(client) -> None: - """CALLBACK_BASE_URL が設定されている場合、x-forwarded-host より優先されることを確認する。""" - with patch.dict(os.environ, {"CALLBACK_BASE_URL": "devforge-dev-XXXXX-an.a.run.app"}): - response = client.get( - "/auth/github/login-url", - headers={ - "Origin": "http://localhost:8788", - "Host": "devforge-dev-XXXXX-an.a.run.app", - "X-Forwarded-Proto": "https", - }, - ) - - assert response.status_code == 200 - parsed = urlparse(response.json()["authorization_url"]) - redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] - assert redirect_uri == "devforge-dev-XXXXX-an.a.run.app/github/callback" - - -def test_github_login_redirect_to_github(client) -> None: - """GET /auth/github/login が GitHub の認可 URL に 303 リダイレクトすることを確認する。""" - response = client.get( - "/auth/github/login", - params={"return_to": "http://localhost:8788/index.html"}, - headers={ - "Host": "devforge-dev-XXXXX-an.a.run.app", - "X-Forwarded-Proto": "https", - }, - follow_redirects=False, - ) - - assert response.status_code == 303 - assert "https://github.com/login/oauth/authorize" in response.headers["location"] - parsed = urlparse(response.headers["location"]) - redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] - assert redirect_uri == "http://localhost:8788/github/callback" - - -def test_github_callback_redirect_returns_error_when_code_missing(client) -> None: - """GET /auth/github/callback は code が無い場合、トークン交換せずエラー付きでフロントへリダイレクトする。 - - state はフロントの sessionStorage で検証する設計のため、サーバー側では検証しない。 - """ - with patch("httpx.AsyncClient") as mock_async_client: - response = client.get( - "/auth/github/callback", - follow_redirects=False, - ) - - assert response.status_code == 200 - assert mock_async_client.call_count == 0 - # 200 + HTML リダイレクト: デフォルトフロント (CORS_ORIGINS[0]) に github_error 付きで戻る - assert "localhost:8788" in response.text - assert "github_error=" in response.text - - -def test_github_callback_redirect_sets_auth_cookie(client) -> None: - """GET /auth/github/callback は code を受け取って認証 Cookie を発行し、デフォルトフロントへリダイレクトする。 - - state 検証と redirect URL の cookie 読み出しはフロント (sessionStorage) に移譲したため、 - リダイレクト先はサーバー側の CORS_ORIGINS[0] にフォールバックする。 - """ - token_response = MagicMock() - token_response.json.return_value = {"access_token": "github-access-token"} - user_response = MagicMock() - user_response.json.return_value = {"id": 12345, "login": "octocat"} - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_http.post.return_value = token_response - mock_http.get.return_value = user_response - mock_cls.return_value = mock_http - - response = client.get( - "/auth/github/callback?code=test-code", - follow_redirects=False, - ) - - assert response.status_code == 200 - # 200 + HTML リダイレクト: フロントエンド URL が HTML 本文に含まれていることを確認する - assert "localhost:8788" in response.text - assert "access_token=" in response.headers["set-cookie"] - - -def test_github_callback_post_does_not_require_cookie(client) -> None: - """POST /auth/github/callback は Cookie の state を検証せずトークン交換に進むことを確認する。 - - state はフロントの sessionStorage で検証済みのためサーバー側では Cookie を見ない。 - """ - token_response = MagicMock() - token_response.json.return_value = {"access_token": "github-access-token"} - user_response = MagicMock() - user_response.json.return_value = {"id": 67890, "login": "octo-post"} - - client.cookies.clear() # Cookie が無くても通ることを確認する - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_http.post.return_value = token_response - mock_http.get.return_value = user_response - mock_cls.return_value = mock_http - - response = client.post( - "/auth/github/callback", - json={"code": "test-code", "state": "any-state-from-frontend"}, - headers={ - "Host": "devforge-dev-XXXXX-an.a.run.app", - "X-Forwarded-Proto": "https", - }, - ) - - assert response.status_code == 200 - assert response.json()["username"] == "github:octo-post" - assert "access_token=" in response.headers["set-cookie"] - # GitHub への redirect_uri も /github/callback でトークン交換していることを確認する - posted_kwargs = mock_http.post.call_args.kwargs - assert posted_kwargs["json"]["redirect_uri"].endswith("/github/callback") - assert "/auth/github/callback" not in posted_kwargs["json"]["redirect_uri"] - - -def test_github_callback_post_uses_frontend_origin_when_callback_base_url_unset(client) -> None: - """ローカル開発では frontend の Origin を redirect_uri の base に使うことを確認する。""" - token_response = MagicMock() - token_response.json.return_value = {"access_token": "github-access-token"} - user_response = MagicMock() - user_response.json.return_value = {"id": 24680, "login": "octo-local"} - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_http.post.return_value = token_response - mock_http.get.return_value = user_response - mock_cls.return_value = mock_http - - response = client.post( - "/auth/github/callback", - json={"code": "test-code", "state": "state-from-frontend"}, - headers={ - "Origin": "http://localhost:8788", - "Host": "localhost:8000", - }, - ) - - assert response.status_code == 200 - posted_kwargs = mock_http.post.call_args.kwargs - assert posted_kwargs["json"]["redirect_uri"] == "http://localhost:8788/github/callback" - - -# ── 不正アクセス追跡: 認証失敗ログ ──────────────────────────────── - - -def _auth_failed_records(records: list[logging.LogRecord]) -> list[logging.LogRecord]: - """auth_failed イベント (devforge ロガー WARNING) のみ抽出する。""" - return [r for r in records if r.name == "devforge" and r.message == "auth_failed"] - - -def test_auth_failed_logged_when_cookie_missing(client, caplog) -> None: - """Cookie 無しで保護 API を叩くと reason=missing_cookie の WARNING が出ることを確認する。""" - client.cookies.clear() - with caplog.at_level(logging.WARNING, logger="devforge"): - response = client.get("/auth/me") - - assert response.status_code == 401 - records = _auth_failed_records(caplog.records) - assert any(getattr(r, "reason", None) == "missing_cookie" for r in records) - - -def test_auth_failed_logged_for_invalid_jwt(client, caplog) -> None: - """不正な JWT で 401 + reason=jwt_decode_error がログされることを確認する。""" - client.cookies.clear() - client.cookies.set("session", json.dumps({"access_token": "not-a-valid-jwt"})) - with caplog.at_level(logging.WARNING, logger="devforge"): - response = client.get("/auth/me") - - assert response.status_code == 401 - records = _auth_failed_records(caplog.records) - assert any(getattr(r, "reason", None) == "jwt_decode_error" for r in records) - - -def test_auth_failed_logged_for_user_not_found(client, caplog) -> None: - """DB に存在しないユーザー名のトークンで reason=user_not_found がログされることを確認する。""" - token = create_access_token("nonexistent-user-xyz") - client.cookies.clear() - client.cookies.set("session", json.dumps({"access_token": token})) - with caplog.at_level(logging.WARNING, logger="devforge"): - response = client.get("/auth/me") - - assert response.status_code == 401 - records = _auth_failed_records(caplog.records) - assert any(getattr(r, "reason", None) == "user_not_found" for r in records) - - -# ── レートリミット ────────────────────────────────────────────── - - -def test_auth_me_rate_limited_after_threshold(client) -> None: - """/auth/me が 60/分の上限を超えると 429 を返すことを確認する。""" - auth_header(client, "rl-user") - limiter.reset() - statuses: list[int] = [] - for _i in range(65): - resp = client.get("/auth/me") - statuses.append(resp.status_code) - if resp.status_code == 429: - break - assert 429 in statuses - # 上限到達前に少なくとも数件は 200 を返している - assert statuses.count(200) >= 50 - limiter.reset() - - -# ── Cookie 属性検証(session Cookie の名称・HttpOnly・SameSite)────────────── - - -def test_github_callback_post_sets_session_cookie_with_httponly(client) -> None: - """POST /auth/github/callback が HttpOnly の session Cookie を設定することを確認する。""" - token_response = MagicMock() - token_response.json.return_value = {"access_token": "github-access-token"} - user_response = MagicMock() - user_response.json.return_value = {"id": 11111, "login": "cookie-test-user"} - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_http.post.return_value = token_response - mock_http.get.return_value = user_response - mock_cls.return_value = mock_http - - response = client.post( - "/auth/github/callback", - json={"code": "test-code", "state": "state-from-frontend"}, - headers={"Origin": "http://localhost:8788"}, - ) - - assert response.status_code == 200 - all_set_cookie = response.headers.get("set-cookie", "") - # session Cookie が設定されていること - assert "session=" in all_set_cookie - # HttpOnly 属性が付与されていること - assert "HttpOnly" in all_set_cookie - # SameSite 属性が付与されていること(ローカルでは lax) - assert "samesite=lax" in all_set_cookie.lower() - # Firebase 時代の __session Cookie 名が使われていないこと - assert "__session=" not in all_set_cookie - - -def test_logout_clears_session_cookie_with_max_age_zero(client) -> None: - """POST /auth/logout が session Cookie を Max-Age=0 で削除することを確認する。""" - auth_header(client, "logout-cookie-attr-user") - - response = client.post("/auth/logout") - - assert response.status_code == 204 - all_set_cookie = response.headers.get("set-cookie", "") - # session Cookie が削除されていること(max-age=0 で上書き) - assert "session=" in all_set_cookie - assert "max-age=0" in all_set_cookie.lower() - # Firebase 時代の __session Cookie 名が使われていないこと - assert "__session=" not in all_set_cookie - - -# 使用しない環境変数を参照するだけのプレースホルダー(lint 対策) -_ = os.environ diff --git a/backend/tests/test_career_analysis_api.py b/backend/tests/test_career_analysis_api.py index fa567fe9..6f02f1b1 100644 --- a/backend/tests/test_career_analysis_api.py +++ b/backend/tests/test_career_analysis_api.py @@ -106,7 +106,11 @@ def test_user_a_blog_article_not_accessible_by_user_b(client: TestClient) -> Non # user A でブログアカウントと記事を作成 headers_a = auth_header(client, "blog-boundary-a") - with patch("app.routers.blog.verify_user_exists", new_callable=AsyncMock, return_value=True): + with patch( + "app.routers.blog.accounts.verify_user_exists", + new_callable=AsyncMock, + return_value=True, + ): resp = client.post( "/api/blog/accounts", json={"platform": "zenn", "username": "boundary-a"}, diff --git a/backend/tests/test_oauth_flow.py b/backend/tests/test_oauth_flow.py deleted file mode 100644 index f95858a5..00000000 --- a/backend/tests/test_oauth_flow.py +++ /dev/null @@ -1,121 +0,0 @@ -"""OAuth フロー(state 生成・検証・frontend URL 解決)のユニットテスト。""" - -from unittest.mock import patch - -import pytest -from app.routers.auth.oauth_flow import ( - normalize_frontend_url, - resolve_frontend_url_from_cookie, - validate_github_oauth_state, -) -from fastapi import HTTPException - -# ── state 検証テスト ───────────────────────────────────────────────────── - - -def test_validate_state_success() -> None: - """state が一致する場合は例外が発生しないこと。""" - validate_github_oauth_state("abc123", "abc123") - - -def test_validate_state_mismatch_raises_401() -> None: - """state が不一致の場合は HTTP 401 が発生すること。""" - with pytest.raises(HTTPException) as exc_info: - validate_github_oauth_state("abc123", "xyz789") - assert exc_info.value.status_code == 401 - - -def test_validate_state_missing_stored_raises_401() -> None: - """stored_state が None の場合は HTTP 401 が発生すること。""" - with pytest.raises(HTTPException) as exc_info: - validate_github_oauth_state(None, "abc123") - assert exc_info.value.status_code == 401 - - -def test_validate_state_missing_provided_raises_401() -> None: - """provided_state が None の場合は HTTP 401 が発生すること。""" - with pytest.raises(HTTPException) as exc_info: - validate_github_oauth_state("abc123", None) - assert exc_info.value.status_code == 401 - - -# ── malformed redirect_uri テスト ─────────────────────────────────────── - - -def test_normalize_frontend_url_invalid_scheme_raises_400() -> None: - """http/https 以外のスキームでは HTTP 400 が発生すること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://example.com"], - ): - with pytest.raises(HTTPException) as exc_info: - normalize_frontend_url("ftp://example.com/path") - assert exc_info.value.status_code == 400 - - -def test_normalize_frontend_url_no_netloc_raises_400() -> None: - """netloc が空の場合は HTTP 400 が発生すること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://example.com"], - ): - with pytest.raises(HTTPException) as exc_info: - normalize_frontend_url("https://") - assert exc_info.value.status_code == 400 - - -# ── frontend URL 許可リスト外テスト ───────────────────────────────────── - - -def test_normalize_frontend_url_not_in_cors_origins_raises_400() -> None: - """CORS_ORIGINS に含まれないオリジンでは HTTP 400 が発生すること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://allowed.example.com"], - ): - with pytest.raises(HTTPException) as exc_info: - normalize_frontend_url("https://evil.example.com/page") - assert exc_info.value.status_code == 400 - - -def test_normalize_frontend_url_allowed_origin_succeeds() -> None: - """CORS_ORIGINS に含まれるオリジンは正常に正規化されること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://allowed.example.com"], - ): - result = normalize_frontend_url("https://allowed.example.com/dashboard") - assert result == "https://allowed.example.com/dashboard" - - -# ── cookie からの URL 解決テスト ──────────────────────────────────────── - - -def test_resolve_frontend_url_from_cookie_valid() -> None: - """有効な Cookie URL が正規化されて返ること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://example.com"], - ): - result = resolve_frontend_url_from_cookie("https://example.com/") - assert result == "https://example.com/" - - -def test_resolve_frontend_url_from_cookie_none_returns_default() -> None: - """Cookie が None の場合はデフォルト URL が返ること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://default.example.com"], - ): - result = resolve_frontend_url_from_cookie(None) - assert result == "https://default.example.com/" - - -def test_resolve_frontend_url_from_cookie_invalid_returns_default() -> None: - """Cookie に不正な URL がある場合はデフォルト URL が返ること。""" - with patch( - "app.routers.auth.oauth_flow.get_cors_origins", - return_value=["https://default.example.com"], - ): - result = resolve_frontend_url_from_cookie("ftp://bad-url.com") - assert result == "https://default.example.com/" diff --git a/backend/tests/test_retry_flow.py b/backend/tests/test_retry_flow.py index e6bded10..245f1ddb 100644 --- a/backend/tests/test_retry_flow.py +++ b/backend/tests/test_retry_flow.py @@ -15,6 +15,7 @@ import asyncio import os +from contextlib import contextmanager from unittest.mock import AsyncMock, patch import pytest @@ -59,6 +60,41 @@ def close(self) -> None: return _Proxy(db) +@contextmanager +def _setup_github_analysis_test( + db_session: Session, + suffix: str, + side_effect, + initial_status: str = "processing", +): + """github_analysis worker テストの共通スキャフォールド。 + + 元の各テストにあった「user 作成 → cache 作成 → worker.SessionLocal / _run_github_analysis / + _create_notification の 3 連 patch」を集約する。15 行 × 4 箇所のコピペを解消するための helper。 + yield する mock_notify で `_create_notification` の呼び出し有無を検証できる。 + """ + user = UserRepository(db_session).create( + f"github:{suffix}", hashed_password=None, email=f"{suffix}@test.com", + ) + cache = GitHubAnalysisCache(user_id=user.id, status=initial_status) + db_session.add(cache) + db_session.commit() + + with ( + patch( + "app.services.tasks.worker.SessionLocal", + return_value=_keep_open_session(db_session), + ), + patch( + "app.services.tasks.worker._run_github_analysis", + new_callable=AsyncMock, + side_effect=side_effect, + ), + patch("app.services.tasks.worker._create_notification") as mock_notify, + ): + yield user, cache, mock_notify + + # ══════════════════════════════════════════════════════════════════════ # execute_task リトライ分岐 # ══════════════════════════════════════════════════════════════════════ @@ -69,25 +105,9 @@ class TestExecuteTaskRetryBranching: def test_non_retryable_error_marks_dead_letter(self, db_session: Session): """NonRetryableError はリトライ回数に関係なく status=dead_letter で終える。""" - user = UserRepository(db_session).create( - "github:nonretry-user", hashed_password=None, email="nonretry@test.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - with ( - patch( - "app.services.tasks.worker.SessionLocal", - return_value=_keep_open_session(db_session), - ), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - side_effect=NonRetryableError("認証不可"), - ), - patch("app.services.tasks.worker._create_notification"), - ): + with _setup_github_analysis_test( + db_session, "nonretry-user", side_effect=NonRetryableError("認証不可"), + ) as (user, cache, _mock_notify): with pytest.raises(NonRetryableError): _run( execute_task( @@ -106,25 +126,11 @@ def test_retryable_error_with_attempts_remaining_marks_retrying( self, db_session: Session, ): """RetryableError で試行回数が残っていれば status=retrying にする。""" - user = UserRepository(db_session).create( - "github:retrying-user", hashed_password=None, email="retrying@test.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - with ( - patch( - "app.services.tasks.worker.SessionLocal", - return_value=_keep_open_session(db_session), - ), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - side_effect=RetryableError("一時エラー", retry_after=10), - ), - patch("app.services.tasks.worker._create_notification") as mock_notify, - ): + with _setup_github_analysis_test( + db_session, + "retrying-user", + side_effect=RetryableError("一時エラー", retry_after=10), + ) as (user, cache, mock_notify): with pytest.raises(RetryableError): _run( execute_task( @@ -146,25 +152,12 @@ def test_retryable_error_on_final_attempt_marks_dead_letter( self, db_session: Session, ): """最終試行(retry_count == max_attempts - 1)で失敗したら dead_letter。""" - user = UserRepository(db_session).create( - "github:deadletter-user", hashed_password=None, email="dl@test.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="retrying") - db_session.add(cache) - db_session.commit() - - with ( - patch( - "app.services.tasks.worker.SessionLocal", - return_value=_keep_open_session(db_session), - ), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - side_effect=RetryableError("最後も失敗"), - ), - patch("app.services.tasks.worker._create_notification") as mock_notify, - ): + with _setup_github_analysis_test( + db_session, + "deadletter-user", + side_effect=RetryableError("最後も失敗"), + initial_status="retrying", + ) as (user, cache, mock_notify): with pytest.raises(RetryableError): _run( execute_task( @@ -185,25 +178,11 @@ def test_retryable_error_on_final_attempt_marks_dead_letter( def test_unknown_exception_treated_as_retryable(self, db_session: Session): """分類されていない例外(RuntimeError 等)は retryable と同様に扱う。""" - user = UserRepository(db_session).create( - "github:unknown-err-user", hashed_password=None, email="unk@test.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - with ( - patch( - "app.services.tasks.worker.SessionLocal", - return_value=_keep_open_session(db_session), - ), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - side_effect=RuntimeError("想定外のクラッシュ"), - ), - patch("app.services.tasks.worker._create_notification"), - ): + with _setup_github_analysis_test( + db_session, + "unknown-err-user", + side_effect=RuntimeError("想定外のクラッシュ"), + ) as (user, cache, _mock_notify): with pytest.raises(RuntimeError): _run( execute_task( @@ -221,25 +200,11 @@ def test_local_default_marks_dead_letter_on_first_failure( self, db_session: Session, ): """ローカル(max_attempts=1 デフォルト)では最初の失敗で即 dead_letter。""" - user = UserRepository(db_session).create( - "github:local-fail-user", hashed_password=None, email="local@test.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - with ( - patch( - "app.services.tasks.worker.SessionLocal", - return_value=_keep_open_session(db_session), - ), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - side_effect=RuntimeError("ローカル失敗"), - ), - patch("app.services.tasks.worker._create_notification"), - ): + with _setup_github_analysis_test( + db_session, + "local-fail-user", + side_effect=RuntimeError("ローカル失敗"), + ) as (user, cache, _mock_notify): with pytest.raises(RuntimeError): # retry_count=0, max_attempts=1(デフォルト) _run(execute_task(TaskType.GITHUB_ANALYSIS, {"user_id": user.id})) @@ -455,7 +420,7 @@ def test_blog_retry_resets_dead_letter_cache(self, client: TestClient): db.commit() with patch( - "app.routers.blog.check_llm_available", new=AsyncMock(return_value=True), + "app.routers.blog.summarize.check_llm_available", new=AsyncMock(return_value=True), ): resp_ok = client.post("/api/blog/summarize/retry", headers=headers) assert resp_ok.status_code == 202 diff --git a/backend/tests/test_security_edges.py b/backend/tests/test_security_edges.py deleted file mode 100644 index 4a26d3ad..00000000 --- a/backend/tests/test_security_edges.py +++ /dev/null @@ -1,661 +0,0 @@ -""" -攻撃者視点のエッジケーステスト。 - -以下のパターンを 1 ファイルに集約する: -- TestNoAuthAccess: 認証なしでのアクセス -- TestAdminTokenRequired: master-data 書込の admin 認可 -- TestInternalSecret: /internal/tasks のなりすまし -- TestIDOR: 他ユーザーリソースの read/update/delete -- TestSQLInjection: path / query / body へのメタ文字注入 -- TestBoundaryValues: 空文字・上限超過・負数 -- TestPathParamInjection: UUID / int 期待箇所への型違反 -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from app.models import ( - BlogAccount, - BlogSummaryCache, - CareerAnalysis, - GitHubAnalysisCache, - Notification, - Resume, - User, -) -from app.repositories import UserRepository -from fastapi.testclient import TestClient -from sqlalchemy import func, select - -from conftest import auth_header - -# ── 共通定数・ヘルパー ────────────────────────────────────────── - - -# 攻撃者が試しがちな SQL インジェクションペイロード。 -SQLI_PAYLOADS: tuple[str, ...] = ( - "' OR '1'='1", - "'; DROP TABLE users;--", - '" OR 1=1 --', - "' UNION SELECT id FROM users --", - "admin'/*", -) - -# 適当な UUID v4 形式の文字列。実在しない resume_id として使う。 -DUMMY_UUID = "00000000-0000-0000-0000-000000000001" - - -_RESUME_PAYLOAD: dict = { - "full_name": "山田 太郎", - "career_summary": "キャリアサマリー", - "self_pr": "自己PR", - "experiences": [], - "qualifications": [], -} - - -def _create_resume(client: TestClient, headers: dict[str, str]) -> str: - """resume を作成し、id を返す。""" - resp = client.post("/api/resumes", json=_RESUME_PAYLOAD, headers=headers) - assert resp.status_code == 201, resp.text - return resp.json()["id"] - - -def _count(db, model) -> int: - """テーブルの行数を取得する。""" - return db.scalar(select(func.count()).select_from(model)) or 0 - - -def _ensure_user(db, username: str) -> User: - """指定 username のユーザーを取得または作成する。auth_header に依存せず直挿しする用途。""" - repo = UserRepository(db) - user = repo.get_by_username(username) - if not user: - user = repo.create(username, hashed_password=None, email=f"{username}@example.com") - return user - - -# ── 1. 認証なしでのアクセス ───────────────────────────────────── - - -_PROTECTED_ENDPOINTS: list[tuple[str, str]] = [ - ("post", "/api/resumes"), - ("get", "/api/resumes/latest"), - ("get", f"/api/resumes/{DUMMY_UUID}"), - ("put", f"/api/resumes/{DUMMY_UUID}"), - ("delete", "/api/resumes"), - ("get", f"/api/resumes/{DUMMY_UUID}/pdf"), - ("get", f"/api/resumes/{DUMMY_UUID}/markdown"), - ("post", "/api/career-analysis/generate"), - ("get", "/api/career-analysis/"), - ("get", "/api/career-analysis/1"), - ("get", "/api/career-analysis/1/status"), - ("post", "/api/career-analysis/1/retry"), - ("delete", "/api/career-analysis/1"), - ("get", "/api/blog/accounts"), - ("post", "/api/blog/accounts"), - ("patch", "/api/blog/accounts/zenn"), - ("delete", "/api/blog/accounts/x"), - ("get", "/api/blog/articles"), - ("post", "/api/blog/accounts/x/sync"), - ("get", "/api/blog/summary-cache"), - ("get", "/api/blog/summary-cache/status"), - ("post", "/api/blog/summarize"), - ("post", "/api/blog/summarize/retry"), - ("get", "/api/blog/score"), - ("get", "/api/intelligence/cache"), - ("get", "/api/intelligence/cache/status"), - ("get", "/api/intelligence/progress"), - ("post", "/api/intelligence/analyze"), - ("post", "/api/intelligence/analyze/retry"), - ("post", "/api/intelligence/position-advice"), - ("get", "/api/notifications"), - ("get", "/api/notifications/unread-count"), - ("patch", "/api/notifications/abc/read"), - ("post", "/api/notifications/read-all"), -] - - -class TestNoAuthAccess: - """Cookie / CSRF なしで保護対象を叩くと 401/403 が返ることを固定化する。""" - - @pytest.mark.parametrize("method,path", _PROTECTED_ENDPOINTS) - def test_protected_endpoint_rejects_unauthenticated( - self, client: TestClient, method: str, path: str - ) -> None: - resp = getattr(client, method)(path) - assert resp.status_code in (401, 403), ( - f"{method.upper()} {path} の status は 401/403 期待だが {resp.status_code}: {resp.text}" - ) - - def test_health_endpoint_is_public(self, client: TestClient) -> None: - assert client.get("/health").status_code == 200 - - def test_master_data_list_is_public(self, client: TestClient) -> None: - """マスタの GET は公開(フロントが認証前に取得する設計)。""" - assert client.get("/api/master-data/qualification").status_code == 200 - assert client.get("/api/master-data/technology-stack").status_code == 200 - - -# ── 2. Admin token 必須の検証 ───────────────────────────────── - - -class TestAdminTokenRequired: - """master-data の書込系は admin Bearer token を要求する。""" - - @pytest.mark.parametrize( - "method,path,body", - [ - ("post", "/api/master-data/qualification", {"name": "x", "sort_order": 0}), - ("put", "/api/master-data/qualification/anything", {"name": "x", "sort_order": 0}), - ("delete", "/api/master-data/qualification/anything", None), - ( - "post", - "/api/master-data/technology-stack", - {"category": "c", "name": "x", "sort_order": 0}, - ), - ( - "put", - "/api/master-data/technology-stack/anything", - {"category": "c", "name": "x", "sort_order": 0}, - ), - ("delete", "/api/master-data/technology-stack/anything", None), - ], - ) - def test_missing_authorization_returns_401( - self, client: TestClient, method: str, path: str, body: dict | None - ) -> None: - if body is None: - resp = getattr(client, method)(path) - else: - resp = getattr(client, method)(path, json=body) - assert resp.status_code == 401 - - def test_wrong_bearer_token_returns_403(self, client: TestClient) -> None: - resp = client.post( - "/api/master-data/qualification", - json={"name": "x", "sort_order": 0}, - headers={"Authorization": "Bearer wrong-token"}, - ) - assert resp.status_code == 403 - - -# ── 3. Internal task endpoint ───────────────────────────────── - - -class TestInternalSecret: - """Cloud Tasks コールバックには X-CloudTasks-QueueName を要求する。""" - - def test_unknown_task_type_returns_400(self, client: TestClient) -> None: - resp = client.post("/internal/tasks/totally-unknown-type", json={}) - assert resp.status_code == 400 - - def test_missing_cloud_tasks_header_returns_403( - self, client: TestClient, monkeypatch: pytest.MonkeyPatch - ) -> None: - """TASK_RUNNER=cloud_tasks では X-CloudTasks-QueueName が無いと 403。""" - monkeypatch.setenv("TASK_RUNNER", "cloud_tasks") - resp = client.post("/internal/tasks/blog_summarize", json={"user_id": "x"}) - assert resp.status_code == 403 - - -# ── 4. IDOR ──────────────────────────────────────────────────── - - -class TestIDOR: - """user A のリソースは user B からは見えない・操作できないことを固定化する。""" - - def test_resume_get_by_id_returns_404_for_other_user(self, client: TestClient) -> None: - headers_a = auth_header(client, "idor-resume-a") - a_id = _create_resume(client, headers_a) - headers_b = auth_header(client, "idor-resume-b") - resp = client.get(f"/api/resumes/{a_id}", headers=headers_b) - assert resp.status_code == 404 - - def test_resume_put_does_not_modify_other_user_data( - self, client: TestClient, db_session - ) -> None: - headers_a = auth_header(client, "idor-resume-put-a") - a_id = _create_resume(client, headers_a) - headers_b = auth_header(client, "idor-resume-put-b") - resp = client.put( - f"/api/resumes/{a_id}", - json={**_RESUME_PAYLOAD, "full_name": "侵入者"}, - headers=headers_b, - ) - assert resp.status_code == 404 - # A の full_name が書き換わっていないこと - user_a = UserRepository(db_session).get_by_username("idor-resume-put-a") - assert user_a is not None - a_resume = db_session.scalar(select(Resume).where(Resume.user_id == user_a.id)) - assert a_resume is not None - assert a_resume.full_name == _RESUME_PAYLOAD["full_name"] - - def test_resume_download_endpoints_reject_other_user(self, client: TestClient) -> None: - headers_a = auth_header(client, "idor-resume-dl-a") - a_id = _create_resume(client, headers_a) - headers_b = auth_header(client, "idor-resume-dl-b") - for suffix in ("pdf", "markdown"): - resp = client.get(f"/api/resumes/{a_id}/{suffix}", headers=headers_b) - assert resp.status_code == 404, f"{suffix} should reject other-user access" - - def test_resume_delete_does_not_touch_other_user_data( - self, client: TestClient, db_session - ) -> None: - """B が DELETE しても A の resume は残り、B 自身は 404。""" - headers_a = auth_header(client, "idor-resume-del-a") - _create_resume(client, headers_a) - headers_b = auth_header(client, "idor-resume-del-b") - resp = client.delete("/api/resumes", headers=headers_b) - assert resp.status_code == 404 - user_a = UserRepository(db_session).get_by_username("idor-resume-del-a") - assert user_a is not None - remaining = db_session.scalar(select(Resume).where(Resume.user_id == user_a.id)) - assert remaining is not None - - def test_career_analysis_status_returns_404_for_other_user( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-career-status-a") - analysis = self._insert_career_analysis(db_session, user_a.id) - headers_b = auth_header(client, "idor-career-status-b") - resp = client.get(f"/api/career-analysis/{analysis.id}/status", headers=headers_b) - assert resp.status_code == 404 - - def test_career_analysis_delete_does_not_touch_other_user_data( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-career-del-a") - analysis = self._insert_career_analysis(db_session, user_a.id) - analysis_id = analysis.id - headers_b = auth_header(client, "idor-career-del-b") - resp = client.delete(f"/api/career-analysis/{analysis_id}", headers=headers_b) - assert resp.status_code == 404 - remaining = db_session.scalar( - select(CareerAnalysis).where(CareerAnalysis.id == analysis_id) - ) - assert remaining is not None - - def test_career_analysis_retry_returns_404_for_other_user( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-career-retry-a") - analysis = self._insert_career_analysis(db_session, user_a.id, status="dead_letter") - headers_b = auth_header(client, "idor-career-retry-b") - resp = client.post(f"/api/career-analysis/{analysis.id}/retry", headers=headers_b) - assert resp.status_code == 404 - - def test_blog_account_delete_does_not_touch_other_user_data( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-blog-del-a") - account = BlogAccount(user_id=user_a.id, platform="zenn", username="user-a") - db_session.add(account) - db_session.commit() - account_id = account.id - headers_b = auth_header(client, "idor-blog-del-b") - resp = client.delete(f"/api/blog/accounts/{account_id}", headers=headers_b) - assert resp.status_code == 404 - remaining = db_session.scalar(select(BlogAccount).where(BlogAccount.id == account_id)) - assert remaining is not None - - def test_blog_account_patch_does_not_touch_other_user_data( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-blog-patch-a") - account = BlogAccount(user_id=user_a.id, platform="qiita", username="user-a") - db_session.add(account) - db_session.commit() - headers_b = auth_header(client, "idor-blog-patch-b") - # 早期 404 のため verify_user_exists には到達しないが、念のためモック - with patch( - "app.routers.blog.verify_user_exists", new_callable=AsyncMock, return_value=True - ): - resp = client.patch( - "/api/blog/accounts/qiita", - json={"username": "intruder"}, - headers=headers_b, - ) - assert resp.status_code == 404 - unchanged = db_session.scalar( - select(BlogAccount).where( - BlogAccount.user_id == user_a.id, BlogAccount.platform == "qiita" - ) - ) - assert unchanged.username == "user-a" - - def test_blog_account_sync_returns_404_for_other_user( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-blog-sync-a") - account = BlogAccount(user_id=user_a.id, platform="zenn", username="user-a") - db_session.add(account) - db_session.commit() - account_id = account.id - headers_b = auth_header(client, "idor-blog-sync-b") - resp = client.post(f"/api/blog/accounts/{account_id}/sync", headers=headers_b) - assert resp.status_code == 404 - - def test_blog_summary_cache_does_not_leak_to_other_user( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-blog-cache-a") - cache_a = BlogSummaryCache( - user_id=user_a.id, summary="A の機密サマリ", status="completed" - ) - db_session.add(cache_a) - db_session.commit() - headers_b = auth_header(client, "idor-blog-cache-b") - resp = client.get("/api/blog/summary-cache", headers=headers_b) - assert resp.status_code == 200 - body = resp.json() - assert body["available"] is False - assert "A の機密サマリ" not in (body.get("summary") or "") - - def test_intelligence_cache_does_not_leak_to_other_user( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-intel-cache-a") - cache_a = GitHubAnalysisCache( - user_id=user_a.id, - analysis_result={"secret": "A だけが見るべきデータ"}, - status="completed", - ) - db_session.add(cache_a) - db_session.commit() - headers_b = auth_header(client, "idor-intel-cache-b") - resp = client.get("/api/intelligence/cache", headers=headers_b) - assert resp.status_code == 200 - body = resp.json() - assert body.get("analysis_result") is None - - def test_notification_mark_read_does_not_touch_other_user( - self, client: TestClient, db_session - ) -> None: - user_a = _ensure_user(db_session, "idor-notif-a") - notification = Notification( - user_id=user_a.id, - task_type="github_analysis", - status="completed", - title="A 宛通知", - is_read=False, - ) - db_session.add(notification) - db_session.commit() - notif_id = notification.id - headers_b = auth_header(client, "idor-notif-b") - resp = client.patch(f"/api/notifications/{notif_id}/read", headers=headers_b) - assert resp.status_code == 404 - unchanged = db_session.scalar(select(Notification).where(Notification.id == notif_id)) - assert unchanged.is_read is False - - @staticmethod - def _insert_career_analysis( - db, user_id: str, *, status: str = "pending" - ) -> CareerAnalysis: - analysis = CareerAnalysis( - user_id=user_id, - version=1, - target_position="Backend", - status=status, - ) - db.add(analysis) - db.commit() - db.refresh(analysis) - return analysis - - -# ── 5. SQL インジェクション ──────────────────────────────────── - - -class TestSQLInjection: - """SQLi メタ文字を流しても 500 にならず・他レコードを壊さず・他ユーザーデータを返さない。""" - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_resume_full_name_treats_payload_as_literal( - self, client: TestClient, db_session, payload: str - ) -> None: - headers = auth_header(client, "sqli-resume") - resp = client.post( - "/api/resumes", - json={**_RESUME_PAYLOAD, "full_name": payload}, - headers=headers, - ) - assert resp.status_code == 201 - latest = client.get("/api/resumes/latest", headers=headers) - assert latest.status_code == 200 - # ペイロードがリテラルとしてそのまま読み出せること - assert latest.json()["full_name"] == payload - # users テーブルが破壊されていないこと(少なくとも 1 行は残る) - assert _count(db_session, User) >= 1 - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_resume_path_param_rejects_sqli( - self, client: TestClient, payload: str - ) -> None: - """resume_id は UUID 型なので SQLi 文字列は DB に到達しない。 - UUID 型違反は 422、URL に ``/`` を含むペイロードはルートが一致せず 404 になるが、 - いずれも「攻撃文字列が DB クエリに乗らない」ことを示すので両方を許容する。 - """ - headers = auth_header(client, "sqli-resume-path") - resp = client.get(f"/api/resumes/{payload}", headers=headers) - assert resp.status_code in (404, 422), resp.text - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_career_analysis_path_param_rejects_sqli( - self, client: TestClient, payload: str - ) -> None: - """analysis_id は int 型。SQLi 文字列は 422、``/`` を含むペイロードは 404。""" - headers = auth_header(client, "sqli-career-path") - resp = client.get(f"/api/career-analysis/{payload}", headers=headers) - assert resp.status_code in (404, 422), resp.text - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_blog_account_path_returns_404_for_sqli( - self, client: TestClient, db_session, payload: str - ) -> None: - """blog account_id は str 型。SQLAlchemy がパラメタライズして 404。""" - headers = auth_header(client, "sqli-blog-account") - resp = client.delete(f"/api/blog/accounts/{payload}", headers=headers) - assert resp.status_code != 500, resp.text - assert resp.status_code in (404, 422) - # users テーブルが破壊されていないこと - assert _count(db_session, User) >= 1 - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_blog_articles_query_filter_handles_sqli( - self, client: TestClient, payload: str - ) -> None: - """?platform= に SQLi を渡しても 200 + 空配列。""" - headers = auth_header(client, "sqli-blog-articles") - resp = client.get("/api/blog/articles", params={"platform": payload}, headers=headers) - assert resp.status_code == 200 - assert resp.json() == [] - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_notification_path_returns_404_for_sqli( - self, client: TestClient, payload: str - ) -> None: - headers = auth_header(client, "sqli-notif") - resp = client.patch(f"/api/notifications/{payload}/read", headers=headers) - assert resp.status_code != 500, resp.text - assert resp.status_code in (404, 422) - - @pytest.mark.parametrize("payload", SQLI_PAYLOADS) - def test_master_data_name_treats_payload_as_literal( - self, client: TestClient, payload: str - ) -> None: - """admin token 経由で SQLi を保存しても、文字列リテラル扱いで他テーブルが壊れない。""" - resp = client.post( - "/api/master-data/qualification", - json={"name": payload, "sort_order": 0}, - headers={"Authorization": "Bearer test-admin-token"}, - ) - assert resp.status_code == 201 - assert resp.json()["name"] == payload - # 一覧 API が落ちずに 1 件返ること(DROP TABLE が解釈されていない証拠) - listed = client.get("/api/master-data/qualification") - assert listed.status_code == 200 - names = [item["name"] for item in listed.json()] - assert payload in names - - -# ── 6. 境界値 ───────────────────────────────────────────────── - - -class TestBoundaryValues: - """空文字・上限超過・負数などで 422 を返すことを固定化する。""" - - def test_resume_full_name_empty_returns_422(self, client: TestClient) -> None: - headers = auth_header(client, "bound-resume-empty") - resp = client.post( - "/api/resumes", - json={**_RESUME_PAYLOAD, "full_name": ""}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_resume_full_name_over_max_length_returns_422(self, client: TestClient) -> None: - headers = auth_header(client, "bound-resume-max") - resp = client.post( - "/api/resumes", - json={**_RESUME_PAYLOAD, "full_name": "あ" * 121}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_resume_career_summary_over_max_length_returns_422( - self, client: TestClient - ) -> None: - headers = auth_header(client, "bound-resume-cs") - resp = client.post( - "/api/resumes", - json={**_RESUME_PAYLOAD, "career_summary": "x" * 2001}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_resume_team_member_count_negative_returns_422(self, client: TestClient) -> None: - """team.members[].count は ge=0 制約。負数は 422。""" - headers = auth_header(client, "bound-resume-team-neg") - payload = { - **_RESUME_PAYLOAD, - "experiences": [ - { - "company": "X", - "business_description": "Y", - "start_date": "2024-01", - "end_date": "2024-12", - "is_current": False, - "clients": [ - { - "name": "", - "projects": [ - { - "name": "p", - "team": { - "total": "5", - "members": [{"role": "SE", "count": -1}], - }, - } - ], - } - ], - } - ], - } - resp = client.post("/api/resumes", json=payload, headers=headers) - assert resp.status_code == 422 - - def test_career_analysis_target_position_over_max_length_returns_422( - self, client: TestClient - ) -> None: - headers = auth_header(client, "bound-career-max") - resp = client.post( - "/api/career-analysis/generate", - json={"target_position": "x" * 201}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_blog_account_username_empty_returns_422(self, client: TestClient) -> None: - headers = auth_header(client, "bound-blog-empty") - resp = client.post( - "/api/blog/accounts", - json={"platform": "zenn", "username": ""}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_blog_account_username_over_max_length_returns_422( - self, client: TestClient - ) -> None: - headers = auth_header(client, "bound-blog-max") - resp = client.post( - "/api/blog/accounts", - json={"platform": "zenn", "username": "x" * 121}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_blog_account_unsupported_platform_returns_422(self, client: TestClient) -> None: - """Literal["zenn", "note", "qiita"] 以外は Pydantic Literal で 422。""" - headers = auth_header(client, "bound-blog-platform") - resp = client.post( - "/api/blog/accounts", - json={"platform": "unknown", "username": "x"}, - headers=headers, - ) - assert resp.status_code == 422 - - def test_master_data_name_empty_returns_422(self, client: TestClient) -> None: - resp = client.post( - "/api/master-data/qualification", - json={"name": "", "sort_order": 0}, - headers={"Authorization": "Bearer test-admin-token"}, - ) - assert resp.status_code == 422 - - def test_master_data_name_over_max_length_returns_422(self, client: TestClient) -> None: - resp = client.post( - "/api/master-data/qualification", - json={"name": "x" * 201, "sort_order": 0}, - headers={"Authorization": "Bearer test-admin-token"}, - ) - assert resp.status_code == 422 - - -# ── 7. パスパラメータ型違反 ──────────────────────────────── - - -class TestPathParamInjection: - """UUID / int 期待箇所への型違反で 422、整数だが範囲外なら 404 を固定化する。""" - - def test_resume_invalid_uuid_returns_422(self, client: TestClient) -> None: - headers = auth_header(client, "path-resume") - resp = client.get("/api/resumes/not-a-uuid", headers=headers) - assert resp.status_code == 422 - - def test_resume_uuid_invalid_format_returns_422(self, client: TestClient) -> None: - """UUID 形式に合わない文字列は 422 で弾かれ、DB に到達しない。""" - headers = auth_header(client, "path-resume-bad") - resp = client.get("/api/resumes/etc-passwd", headers=headers) - assert resp.status_code == 422 - - def test_career_analysis_alpha_id_returns_422(self, client: TestClient) -> None: - headers = auth_header(client, "path-career-alpha") - resp = client.get("/api/career-analysis/abc", headers=headers) - assert resp.status_code == 422 - - def test_career_analysis_float_id_returns_422(self, client: TestClient) -> None: - headers = auth_header(client, "path-career-float") - resp = client.delete("/api/career-analysis/1.5", headers=headers) - assert resp.status_code == 422 - - def test_career_analysis_negative_id_returns_404(self, client: TestClient) -> None: - """負数 ID は int としてパースされるが DB ヒットしないため 404。""" - headers = auth_header(client, "path-career-neg") - resp = client.get("/api/career-analysis/-1", headers=headers) - assert resp.status_code == 404 diff --git a/backend/tests/test_worker/__init__.py b/backend/tests/test_worker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_worker/_helpers.py b/backend/tests/test_worker/_helpers.py new file mode 100644 index 00000000..4feb9c07 --- /dev/null +++ b/backend/tests/test_worker/_helpers.py @@ -0,0 +1,12 @@ +"""tests/test_worker パッケージ内で共有するヘルパ。""" + +import asyncio + + +def run_sync(coro): + """async 関数を同期的に実行するヘルパー。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() diff --git a/backend/tests/test_worker/test_blog_summarize.py b/backend/tests/test_worker/test_blog_summarize.py new file mode 100644 index 00000000..c1eb112b --- /dev/null +++ b/backend/tests/test_worker/test_blog_summarize.py @@ -0,0 +1,181 @@ +"""_run_blog_summarize の単体テスト。""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.models import BlogSummaryCache +from app.repositories import UserRepository +from app.services.tasks.worker import _run_blog_summarize +from sqlalchemy.orm import Session + +from ._helpers import run_sync as _run + + +class TestRunBlogSummarize: + def _make_user_and_cache(self, db: Session, username="blog-worker-user"): + user = UserRepository(db).create( + username, + hashed_password=None, + email=f"{username}@test.com", + ) + cache = BlogSummaryCache(user_id=user.id, status="pending") + db.add(cache) + db.commit() + return user, cache + + def _make_mock_repo(self, articles=None): + """BlogArticleRepository のモックを返す。articles 省略時は記事1件。""" + art = MagicMock() + art.title = "テスト記事" + art.url = "https://zenn.dev/test/articles/test" + art.published_at = "2024-01-01" + art.likes_count = 5 + art.summary = "要約" + art.tags = ["Python"] + art.platform = "zenn" + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = articles if articles is not None else [art] + return mock_repo + + def test_success_status_completed(self, db_session: Session): + """正常系: LLM が要約を返し、status が completed になること。""" + user, cache = self._make_user_and_cache(db_session) + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=True) + + with ( + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + patch( + "app.services.intelligence.llm_summarizer.summarize_blog_articles", + new_callable=AsyncMock, + return_value="AI 要約テキスト", + ), + ): + _run( + _run_blog_summarize( + db_session, + {"user_id": user.id}, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.summary == "AI 要約テキスト" + assert cache.completed_at is not None + assert cache.expires_at is not None + + def test_llm_unavailable_sets_dead_letter(self, db_session: Session): + """LLM が利用不可の場合に status が dead_letter になること。""" + user, cache = self._make_user_and_cache(db_session, "blog-nollm") + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=False) + + with ( + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + ): + _run( + _run_blog_summarize( + db_session, + {"user_id": user.id}, + ) + ) + + db_session.refresh(cache) + assert cache.status == "dead_letter" + assert cache.error_message is not None + + def test_empty_summary_sets_dead_letter(self, db_session: Session): + """LLM が空文字を返した場合に status が dead_letter になること。""" + user, cache = self._make_user_and_cache(db_session, "blog-empty") + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=True) + + with ( + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + patch( + "app.services.intelligence.llm_summarizer.summarize_blog_articles", + new_callable=AsyncMock, + return_value="", + ), + ): + _run( + _run_blog_summarize( + db_session, + {"user_id": user.id}, + ) + ) + + db_session.refresh(cache) + assert cache.status == "dead_letter" + + def test_no_articles_sets_dead_letter(self, db_session: Session): + """記事が 0 件の場合に status が dead_letter になること。""" + user, cache = self._make_user_and_cache(db_session, "blog-no-articles") + + with patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(articles=[]), + ): + _run( + _run_blog_summarize( + db_session, + {"user_id": user.id}, + ) + ) + + db_session.refresh(cache) + assert cache.status == "dead_letter" + assert cache.error_message == "分析対象の記事がありません" + + def test_no_cache_raises_non_retryable(self, db_session: Session): + """キャッシュが見つからない場合、NonRetryableError を送出すること。""" + from app.services.tasks.exceptions import NonRetryableError + + with pytest.raises(NonRetryableError): + _run( + _run_blog_summarize( + db_session, + {"user_id": "ghost-user"}, + ) + ) + + def test_status_set_to_processing_before_llm_call(self, db_session: Session): + """LLM 呼び出し前に status が processing に更新されること。""" + user, cache = self._make_user_and_cache(db_session, "blog-processing") + processing_status = [] + mock_llm = MagicMock() + + async def _fake_check_available(): + db_session.refresh(cache) + processing_status.append(cache.status) + return False + + mock_llm.check_available = _fake_check_available + + with ( + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + ): + _run( + _run_blog_summarize( + db_session, + {"user_id": user.id}, + ) + ) + + assert "processing" in processing_status diff --git a/backend/tests/test_worker/test_career_analysis.py b/backend/tests/test_worker/test_career_analysis.py new file mode 100644 index 00000000..534b97c6 --- /dev/null +++ b/backend/tests/test_worker/test_career_analysis.py @@ -0,0 +1,162 @@ +"""_run_career_analysis と _mark_dead_letter (career_analysis) の単体テスト。""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.models.career_analysis import CareerAnalysis +from app.repositories import UserRepository +from app.services.tasks.base import TaskType +from app.services.tasks.worker import ( + _mark_dead_letter, + _run_career_analysis, +) +from sqlalchemy.orm import Session + +from ._helpers import run_sync as _run + + +class TestRunCareerAnalysis: + def _make_user_and_analysis(self, db: Session, username="career-worker-user"): + user = UserRepository(db).create( + username, + hashed_password=None, + email=f"{username}@test.com", + ) + analysis = CareerAnalysis( + user_id=user.id, + version=1, + target_position="バックエンドエンジニア", + status="pending", + ) + db.add(analysis) + db.commit() + return user, analysis + + def test_success_status_completed(self, db_session: Session): + """正常系: キャリア分析が完了し status が completed になること。""" + user, analysis = self._make_user_and_analysis(db_session) + mock_llm = MagicMock() + fake_result = {"strengths": [], "career_paths": [], "action_items": []} + + with ( + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + patch( + "app.services.career_analysis.builder.build_career_analysis", + new_callable=AsyncMock, + return_value=fake_result, + ), + ): + _run( + _run_career_analysis( + db_session, + { + "user_id": user.id, + "record_id": analysis.id, + "target_position": "バックエンドエンジニア", + }, + ) + ) + + db_session.refresh(analysis) + assert analysis.status == "completed" + assert analysis.result_json is not None + assert analysis.completed_at is not None + + def test_value_error_sets_dead_letter(self, db_session: Session): + """build_career_analysis が ValueError を送出した場合 status が dead_letter になること。""" + user, analysis = self._make_user_and_analysis(db_session, "career-err") + mock_llm = MagicMock() + + with ( + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), + patch( + "app.services.career_analysis.builder.build_career_analysis", + new_callable=AsyncMock, + side_effect=ValueError("必要なデータが不足しています"), + ), + ): + with pytest.raises(ValueError): + _run( + _run_career_analysis( + db_session, + { + "user_id": user.id, + "record_id": analysis.id, + "target_position": "バックエンドエンジニア", + }, + ) + ) + + db_session.refresh(analysis) + assert analysis.status == "dead_letter" + assert analysis.error_message is not None + assert "不足" in analysis.error_message + + def test_no_record_raises_non_retryable(self, db_session: Session): + """レコードが見つからない場合、NonRetryableError が送出されること。 + + 旧契約(silent return)は worker から completed と誤って観測される回帰を招くため、 + ``dead_letter`` への遷移を強制する。 + """ + from app.services.tasks.exceptions import NonRetryableError + + with pytest.raises(NonRetryableError): + _run( + _run_career_analysis( + db_session, + { + "user_id": "ghost", + "record_id": 99999, + "target_position": "test", + }, + ) + ) + + +class TestMarkDeadLetterCareerAnalysis: + def test_mark_dead_letter_career_analysis(self, db_session: Session): + """_mark_dead_letter が CareerAnalysis のステータスを dead_letter に更新すること。""" + user = UserRepository(db_session).create( + "mf-career-user", hashed_password=None, email="mfcareer@test.com" + ) + analysis = CareerAnalysis( + user_id=user.id, + version=1, + target_position="SRE", + status="processing", + ) + db_session.add(analysis) + db_session.commit() + + _mark_dead_letter( + db_session, + TaskType.CAREER_ANALYSIS, + {"user_id": user.id, "record_id": analysis.id}, + ) + + db_session.refresh(analysis) + assert analysis.status == "dead_letter" + assert analysis.error_message == "予期しないエラーが発生しました" + + def test_mark_dead_letter_does_not_overwrite_completed(self, db_session: Session): + """completed 済みのレコードは _mark_dead_letter で上書きされないこと。""" + user = UserRepository(db_session).create( + "mf-career-done", hashed_password=None, email="mfcareerdone@test.com" + ) + analysis = CareerAnalysis( + user_id=user.id, + version=1, + target_position="SRE", + status="completed", + ) + db_session.add(analysis) + db_session.commit() + + _mark_dead_letter( + db_session, + TaskType.CAREER_ANALYSIS, + {"user_id": user.id, "record_id": analysis.id}, + ) + + db_session.refresh(analysis) + assert analysis.status == "completed" diff --git a/backend/tests/test_worker/test_execute_task.py b/backend/tests/test_worker/test_execute_task.py new file mode 100644 index 00000000..f50839d7 --- /dev/null +++ b/backend/tests/test_worker/test_execute_task.py @@ -0,0 +1,138 @@ +"""execute_task のルーティングロジックと _safe_rollback の単体テスト。""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.models import BlogSummaryCache +from app.repositories import UserRepository +from app.services.tasks.base import TaskType +from app.services.tasks.worker import ( + _safe_rollback, + execute_task, +) +from sqlalchemy.orm import Session + +from ._helpers import run_sync as _run + + +class TestExecuteTask: + def test_known_task_type_routes_to_correct_handler(self, db_session: Session): + """GITHUB_ANALYSIS が _run_github_analysis に正しくディスパッチされ、 + 他のハンドラ関数は呼ばれないことを確認する。""" + with ( + patch("app.services.tasks.worker.SessionLocal", return_value=db_session), + patch( + "app.services.tasks.worker._run_github_analysis", + new_callable=AsyncMock, + ) as mock_gh, + patch( + "app.services.tasks.worker._run_blog_summarize", + new_callable=AsyncMock, + ) as mock_blog, + patch( + "app.services.tasks.worker._run_career_analysis", + new_callable=AsyncMock, + ) as mock_career, + patch("app.services.tasks.worker._create_notification"), + ): + _run( + execute_task( + TaskType.GITHUB_ANALYSIS, + {"user_id": "test-user", "github_username": "u"}, + ) + ) + + mock_gh.assert_called_once() + mock_blog.assert_not_called() + mock_career.assert_not_called() + + def test_execute_task_marks_dead_letter_on_error(self, db_session: Session): + """予期しない例外が発生した場合(max_attempts=1)、_mark_dead_letter が + 呼ばれ例外が再 raise されること。""" + mock_db = MagicMock() + mock_session_local = MagicMock(return_value=mock_db) + + with ( + patch("app.services.tasks.worker.SessionLocal", mock_session_local), + patch( + "app.services.tasks.worker._run_github_analysis", + new_callable=AsyncMock, + side_effect=RuntimeError("予期しないクラッシュ"), + ), + patch("app.services.tasks.worker._mark_dead_letter") as mock_mark_dead_letter, + patch("app.services.tasks.worker._create_notification"), + ): + with pytest.raises(RuntimeError, match="予期しないクラッシュ"): + _run( + execute_task( + TaskType.GITHUB_ANALYSIS, + {"user_id": "test-user-id", "github_username": "u"}, + ) + ) + + mock_mark_dead_letter.assert_called_once() + call_args = mock_mark_dead_letter.call_args + assert call_args.args == ( + mock_db, + TaskType.GITHUB_ANALYSIS, + {"user_id": "test-user-id", "github_username": "u"}, + ) + assert isinstance(call_args.kwargs.get("error"), RuntimeError) + + def test_execute_task_creates_notification_on_success(self, db_session: Session): + """タスク成功時に _create_notification が呼ばれること。""" + mock_db = MagicMock() + mock_session_local = MagicMock(return_value=mock_db) + + with ( + patch("app.services.tasks.worker.SessionLocal", mock_session_local), + patch( + "app.services.tasks.worker._run_blog_summarize", + new_callable=AsyncMock, + return_value=None, + ), + patch("app.services.tasks.worker._create_notification") as mock_notify, + ): + _run( + execute_task( + TaskType.BLOG_SUMMARIZE, + {"user_id": "notif-test-user"}, + ) + ) + + mock_notify.assert_called_once_with( + mock_db, TaskType.BLOG_SUMMARIZE, "notif-test-user", "completed" + ) + + +class TestSafeRollback: + def test_rollback_after_failed_commit_restores_session(self, db_session: Session): + """DB commit 失敗後に _safe_rollback を呼ぶと、セッションが再利用可能になること。""" + user = UserRepository(db_session).create( + "rollback-test-user", hashed_password=None, email="rollback@test.com" + ) + cache = BlogSummaryCache(user_id=user.id, status="processing") + db_session.add(cache) + db_session.commit() + + # コミット失敗をシミュレートしてセッションを PendingRollback 状態にする + db_session.execute.__self__ if hasattr(db_session.execute, "__self__") else None + db_session.rollback() # まず手動でロールバックして dirty 状態を作る + + # _safe_rollback は例外を上げないこと + _safe_rollback(db_session) + + # ロールバック後にセッションが再利用可能であること + cache.status = "dead_letter" + db_session.commit() + db_session.refresh(cache) + assert cache.status == "dead_letter" + + def test_safe_rollback_suppresses_exception(self): + """rollback() が例外を送出しても _safe_rollback は例外を外に漏らさないこと。""" + mock_db = MagicMock() + mock_db.rollback.side_effect = Exception("DB 接続断") + + # 例外が外に漏れないこと + _safe_rollback(mock_db) + mock_db.rollback.assert_called_once() diff --git a/backend/tests/test_worker/test_github_analysis.py b/backend/tests/test_worker/test_github_analysis.py new file mode 100644 index 00000000..5d38331b --- /dev/null +++ b/backend/tests/test_worker/test_github_analysis.py @@ -0,0 +1,275 @@ +"""_run_github_analysis と _generate_advice_if_available の単体テスト。""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.models import GitHubAnalysisCache +from app.repositories import UserRepository +from app.services.intelligence.github_analysis_service import ( + _generate_advice_if_available, +) +from app.services.tasks.worker import _run_github_analysis +from sqlalchemy.orm import Session + +from ._helpers import run_sync as _run + + +class TestRunGithubAnalysis: + def _make_user_and_cache(self, db: Session, username="github:gh-user"): + user = UserRepository(db).create( + username, + hashed_password=None, + email=f"{username}@test.com", + ) + cache = GitHubAnalysisCache(user_id=user.id, status="pending") + db.add(cache) + db.commit() + return user, cache + + def _sample_repos(self): + from app.services.intelligence.github_collector import RepoData + + return [ + RepoData( + name="repo1", + owner="gh-user", + description="", + languages={"Python": 10000}, + topics=["fastapi"], + created_at="2023-01-01T00:00:00Z", + pushed_at="2024-01-01T00:00:00Z", + fork=False, + stargazers_count=0, + default_branch="main", + dependencies=[], + root_files=[], + detected_frameworks=[], + detected_devtools=[], + detected_infras=[], + ) + ] + + def test_status_transitions_to_completed(self, db_session: Session): + """正常系: status が completed に遷移すること。""" + user, cache = self._make_user_and_cache(db_session) + repos = self._sample_repos() + + with ( + patch( + "app.services.intelligence.github_analysis_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch( + "app.services.progress_service.set_progress", + new_callable=AsyncMock, + ), + patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=MagicMock(check_available=AsyncMock(return_value=False)), + ), + patch( + "app.services.intelligence.github_analysis_service.decrypt_field", + return_value="token123", + ), + ): + _run( + _run_github_analysis( + db_session, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.analysis_result is not None + assert cache.completed_at is not None + + def test_status_transitions_to_processing_at_start(self, db_session: Session): + """タスク開始時に status が processing に更新されること。""" + user, cache = self._make_user_and_cache(db_session) + repos = self._sample_repos() + + processing_status = [] + + async def _fake_collect(**kwargs): + db_session.refresh(cache) + processing_status.append(cache.status) + return repos + + with ( + patch( + "app.services.intelligence.github_analysis_service.collect_repos", + side_effect=_fake_collect, + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=MagicMock(check_available=AsyncMock(return_value=False)), + ), + patch( + "app.services.intelligence.github_analysis_service.decrypt_field", + return_value=None, + ), + ): + _run( + _run_github_analysis( + db_session, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": None, + "include_forks": False, + }, + ) + ) + + assert "processing" in processing_status + + def test_github_user_not_found_sets_dead_letter(self, db_session: Session): + """GitHubUserNotFoundError 発生時に status が dead_letter になること。""" + from app.services.intelligence.github.api_client import GitHubUserNotFoundError + + user, cache = self._make_user_and_cache(db_session, "github:notfound") + + with ( + patch( + "app.services.intelligence.github_analysis_service.collect_repos", + new_callable=AsyncMock, + side_effect=GitHubUserNotFoundError("notfound"), + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_analysis_service.decrypt_field", + return_value=None, + ), + ): + with pytest.raises(GitHubUserNotFoundError): + _run( + _run_github_analysis( + db_session, + { + "user_id": user.id, + "github_username": "notfound", + "github_token": None, + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "dead_letter" + + def test_no_cache_raises_non_retryable(self, db_session: Session): + """キャッシュが見つからない場合、NonRetryableError が送出されること。 + + worker 側で ``dead_letter`` 遷移と通知発行を行わせる契約。 + """ + from app.services.tasks.exceptions import NonRetryableError + + with pytest.raises(NonRetryableError): + _run( + _run_github_analysis( + db_session, + { + "user_id": "nonexistent-user-id", + "github_username": "nobody", + "github_token": None, + "include_forks": False, + }, + ) + ) + + +class TestGenerateAdviceIfAvailable: + def _analysis(self): + return { + "repos_analyzed": 5, + "languages": {"Python": 50000}, + "position_scores": { + "backend": 70, + "frontend": 20, + "fullstack": 45, + "sre": 25, + "cloud": 15, + "missing_skills": [], + }, + } + + def test_llm_unavailable_returns_none(self): + """LLM が利用不可の場合 (None, False) を返すこと。""" + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=False) + + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): + result = _run(_generate_advice_if_available(self._analysis())) + + assert result == (None, False) + + def test_llm_available_returns_advice(self): + """LLM が利用可能の場合、(アドバイス文字列, False) を返すこと。""" + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=True) + + with ( + patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ), + patch( + "app.services.intelligence.github_analysis_service.generate_learning_advice", + new_callable=AsyncMock, + return_value="学習アドバイスです", + ), + ): + result = _run(_generate_advice_if_available(self._analysis())) + + assert result == ("学習アドバイスです", False) + + def test_llm_retryable_error_returns_warning(self): + """LLM が想定内の RetryableError を送出した場合 (None, True) を返し例外を握りつぶすこと。""" + from app.services.tasks.exceptions import RetryableError + + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(side_effect=RetryableError("LLM 一時障害")) + + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): + result = _run(_generate_advice_if_available(self._analysis())) + + assert result == (None, True) + + def test_llm_unexpected_exception_propagates(self): + """LLM が予期しない例外を送出した場合は再送出されること(プログラミングエラー検知のため)。""" + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) + + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): + with pytest.raises(Exception, match="LLM クラッシュ"): + _run(_generate_advice_if_available(self._analysis())) + + def test_no_position_scores_returns_none(self): + """position_scores が None の場合 (None, False) を返すこと。""" + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(return_value=True) + + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): + result = _run(_generate_advice_if_available({"repos_analyzed": 5})) + + assert result == (None, False) diff --git a/backend/tests/test_worker_extended.py b/backend/tests/test_worker_extended.py deleted file mode 100644 index d78d135e..00000000 --- a/backend/tests/test_worker_extended.py +++ /dev/null @@ -1,762 +0,0 @@ -""" -worker の execute_task および各タスクランナーのテスト(状態遷移・フロー検証)。 - -対象モジュール: app.services.tasks.worker -テスト方針: - - _run_github_analysis / _run_blog_summarize / _run_career_analysis を直接テスト - - 外部サービス(LLM, GitHub API, Redis)はすべてモック化 - - DB はフィクスチャの一時 SQLite セッションを使用 - - execute_task のルーティングロジックも SessionLocal パッチで確認 -""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from app.models import BlogSummaryCache, GitHubAnalysisCache -from app.models.career_analysis import CareerAnalysis -from app.repositories import UserRepository -from app.services.intelligence.github_analysis_service import ( - _generate_advice_if_available, -) -from app.services.tasks.base import TaskType -from app.services.tasks.worker import ( - _mark_dead_letter, - _run_blog_summarize, - _run_career_analysis, - _run_github_analysis, - _safe_rollback, - execute_task, -) -from sqlalchemy.orm import Session - - -def _run(coro): - """async 関数を同期的に実行するヘルパー。""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -# ── _run_github_analysis ────────────────────────────────────────────────── - - -class TestRunGithubAnalysis: - def _make_user_and_cache(self, db: Session, username="github:gh-user"): - user = UserRepository(db).create( - username, - hashed_password=None, - email=f"{username}@test.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="pending") - db.add(cache) - db.commit() - return user, cache - - def _sample_repos(self): - from app.services.intelligence.github_collector import RepoData - - return [ - RepoData( - name="repo1", - owner="gh-user", - description="", - languages={"Python": 10000}, - topics=["fastapi"], - created_at="2023-01-01T00:00:00Z", - pushed_at="2024-01-01T00:00:00Z", - fork=False, - stargazers_count=0, - default_branch="main", - dependencies=[], - root_files=[], - detected_frameworks=[], - detected_devtools=[], - detected_infras=[], - ) - ] - - def test_status_transitions_to_completed(self, db_session: Session): - """正常系: status が completed に遷移すること。""" - user, cache = self._make_user_and_cache(db_session) - repos = self._sample_repos() - - with ( - patch( - "app.services.intelligence.github_analysis_service.collect_repos", - new_callable=AsyncMock, - return_value=repos, - ), - patch( - "app.services.progress_service.set_progress", - new_callable=AsyncMock, - ), - patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=MagicMock(check_available=AsyncMock(return_value=False)), - ), - patch( - "app.services.intelligence.github_analysis_service.decrypt_field", - return_value="token123", - ), - ): - _run( - _run_github_analysis( - db_session, - { - "user_id": user.id, - "github_username": "gh-user", - "github_token": "encrypted_token", - "include_forks": False, - }, - ) - ) - - db_session.refresh(cache) - assert cache.status == "completed" - assert cache.analysis_result is not None - assert cache.completed_at is not None - - def test_status_transitions_to_processing_at_start(self, db_session: Session): - """タスク開始時に status が processing に更新されること。""" - user, cache = self._make_user_and_cache(db_session) - repos = self._sample_repos() - - processing_status = [] - - async def _fake_collect(**kwargs): - db_session.refresh(cache) - processing_status.append(cache.status) - return repos - - with ( - patch( - "app.services.intelligence.github_analysis_service.collect_repos", - side_effect=_fake_collect, - ), - patch("app.services.progress_service.set_progress", new_callable=AsyncMock), - patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=MagicMock(check_available=AsyncMock(return_value=False)), - ), - patch( - "app.services.intelligence.github_analysis_service.decrypt_field", - return_value=None, - ), - ): - _run( - _run_github_analysis( - db_session, - { - "user_id": user.id, - "github_username": "gh-user", - "github_token": None, - "include_forks": False, - }, - ) - ) - - assert "processing" in processing_status - - def test_github_user_not_found_sets_dead_letter(self, db_session: Session): - """GitHubUserNotFoundError 発生時に status が dead_letter になること。""" - from app.services.intelligence.github.api_client import GitHubUserNotFoundError - - user, cache = self._make_user_and_cache(db_session, "github:notfound") - - with ( - patch( - "app.services.intelligence.github_analysis_service.collect_repos", - new_callable=AsyncMock, - side_effect=GitHubUserNotFoundError("notfound"), - ), - patch("app.services.progress_service.set_progress", new_callable=AsyncMock), - patch( - "app.services.intelligence.github_analysis_service.decrypt_field", - return_value=None, - ), - ): - with pytest.raises(GitHubUserNotFoundError): - _run( - _run_github_analysis( - db_session, - { - "user_id": user.id, - "github_username": "notfound", - "github_token": None, - "include_forks": False, - }, - ) - ) - - db_session.refresh(cache) - assert cache.status == "dead_letter" - - def test_no_cache_raises_non_retryable(self, db_session: Session): - """キャッシュが見つからない場合、NonRetryableError が送出されること。 - - worker 側で ``dead_letter`` 遷移と通知発行を行わせる契約。 - """ - from app.services.tasks.exceptions import NonRetryableError - - with pytest.raises(NonRetryableError): - _run( - _run_github_analysis( - db_session, - { - "user_id": "nonexistent-user-id", - "github_username": "nobody", - "github_token": None, - "include_forks": False, - }, - ) - ) - - -# ── _run_blog_summarize ─────────────────────────────────────────────────── - - -class TestRunBlogSummarize: - def _make_user_and_cache(self, db: Session, username="blog-worker-user"): - user = UserRepository(db).create( - username, - hashed_password=None, - email=f"{username}@test.com", - ) - cache = BlogSummaryCache(user_id=user.id, status="pending") - db.add(cache) - db.commit() - return user, cache - - def _make_mock_repo(self, articles=None): - """BlogArticleRepository のモックを返す。articles 省略時は記事1件。""" - art = MagicMock() - art.title = "テスト記事" - art.url = "https://zenn.dev/test/articles/test" - art.published_at = "2024-01-01" - art.likes_count = 5 - art.summary = "要約" - art.tags = ["Python"] - art.platform = "zenn" - mock_repo = MagicMock() - mock_repo.list_by_user.return_value = articles if articles is not None else [art] - return mock_repo - - def test_success_status_completed(self, db_session: Session): - """正常系: LLM が要約を返し、status が completed になること。""" - user, cache = self._make_user_and_cache(db_session) - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(return_value=True) - - with ( - patch( - "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", - return_value=self._make_mock_repo(), - ), - patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), - patch( - "app.services.intelligence.llm_summarizer.summarize_blog_articles", - new_callable=AsyncMock, - return_value="AI 要約テキスト", - ), - ): - _run( - _run_blog_summarize( - db_session, - {"user_id": user.id}, - ) - ) - - db_session.refresh(cache) - assert cache.status == "completed" - assert cache.summary == "AI 要約テキスト" - assert cache.completed_at is not None - assert cache.expires_at is not None - - def test_llm_unavailable_sets_dead_letter(self, db_session: Session): - """LLM が利用不可の場合に status が dead_letter になること。""" - user, cache = self._make_user_and_cache(db_session, "blog-nollm") - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(return_value=False) - - with ( - patch( - "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", - return_value=self._make_mock_repo(), - ), - patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), - ): - _run( - _run_blog_summarize( - db_session, - {"user_id": user.id}, - ) - ) - - db_session.refresh(cache) - assert cache.status == "dead_letter" - assert cache.error_message is not None - - def test_empty_summary_sets_dead_letter(self, db_session: Session): - """LLM が空文字を返した場合に status が dead_letter になること。""" - user, cache = self._make_user_and_cache(db_session, "blog-empty") - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(return_value=True) - - with ( - patch( - "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", - return_value=self._make_mock_repo(), - ), - patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), - patch( - "app.services.intelligence.llm_summarizer.summarize_blog_articles", - new_callable=AsyncMock, - return_value="", - ), - ): - _run( - _run_blog_summarize( - db_session, - {"user_id": user.id}, - ) - ) - - db_session.refresh(cache) - assert cache.status == "dead_letter" - - def test_no_articles_sets_dead_letter(self, db_session: Session): - """記事が 0 件の場合に status が dead_letter になること。""" - user, cache = self._make_user_and_cache(db_session, "blog-no-articles") - - with patch( - "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", - return_value=self._make_mock_repo(articles=[]), - ): - _run( - _run_blog_summarize( - db_session, - {"user_id": user.id}, - ) - ) - - db_session.refresh(cache) - assert cache.status == "dead_letter" - assert cache.error_message == "分析対象の記事がありません" - - def test_no_cache_raises_non_retryable(self, db_session: Session): - """キャッシュが見つからない場合、NonRetryableError を送出すること。""" - from app.services.tasks.exceptions import NonRetryableError - - with pytest.raises(NonRetryableError): - _run( - _run_blog_summarize( - db_session, - {"user_id": "ghost-user"}, - ) - ) - - def test_status_set_to_processing_before_llm_call(self, db_session: Session): - """LLM 呼び出し前に status が processing に更新されること。""" - user, cache = self._make_user_and_cache(db_session, "blog-processing") - processing_status = [] - mock_llm = MagicMock() - - async def _fake_check_available(): - db_session.refresh(cache) - processing_status.append(cache.status) - return False - - mock_llm.check_available = _fake_check_available - - with ( - patch( - "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", - return_value=self._make_mock_repo(), - ), - patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), - ): - _run( - _run_blog_summarize( - db_session, - {"user_id": user.id}, - ) - ) - - assert "processing" in processing_status - - -# ── _run_career_analysis ────────────────────────────────────────────────── - - -class TestRunCareerAnalysis: - def _make_user_and_analysis(self, db: Session, username="career-worker-user"): - user = UserRepository(db).create( - username, - hashed_password=None, - email=f"{username}@test.com", - ) - analysis = CareerAnalysis( - user_id=user.id, - version=1, - target_position="バックエンドエンジニア", - status="pending", - ) - db.add(analysis) - db.commit() - return user, analysis - - def test_success_status_completed(self, db_session: Session): - """正常系: キャリア分析が完了し status が completed になること。""" - user, analysis = self._make_user_and_analysis(db_session) - mock_llm = MagicMock() - fake_result = {"strengths": [], "career_paths": [], "action_items": []} - - with ( - patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), - patch( - "app.services.career_analysis.builder.build_career_analysis", - new_callable=AsyncMock, - return_value=fake_result, - ), - ): - _run( - _run_career_analysis( - db_session, - { - "user_id": user.id, - "record_id": analysis.id, - "target_position": "バックエンドエンジニア", - }, - ) - ) - - db_session.refresh(analysis) - assert analysis.status == "completed" - assert analysis.result_json is not None - assert analysis.completed_at is not None - - def test_value_error_sets_dead_letter(self, db_session: Session): - """build_career_analysis が ValueError を送出した場合 status が dead_letter になること。""" - user, analysis = self._make_user_and_analysis(db_session, "career-err") - mock_llm = MagicMock() - - with ( - patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), - patch( - "app.services.career_analysis.builder.build_career_analysis", - new_callable=AsyncMock, - side_effect=ValueError("必要なデータが不足しています"), - ), - ): - with pytest.raises(ValueError): - _run( - _run_career_analysis( - db_session, - { - "user_id": user.id, - "record_id": analysis.id, - "target_position": "バックエンドエンジニア", - }, - ) - ) - - db_session.refresh(analysis) - assert analysis.status == "dead_letter" - assert analysis.error_message is not None - assert "不足" in analysis.error_message - - def test_no_record_raises_non_retryable(self, db_session: Session): - """レコードが見つからない場合、NonRetryableError が送出されること。 - - 旧契約(silent return)は worker から completed と誤って観測される回帰を招くため、 - ``dead_letter`` への遷移を強制する。 - """ - from app.services.tasks.exceptions import NonRetryableError - - with pytest.raises(NonRetryableError): - _run( - _run_career_analysis( - db_session, - { - "user_id": "ghost", - "record_id": 99999, - "target_position": "test", - }, - ) - ) - - -# ── _generate_advice_if_available ───────────────────────────────────────── - - -class TestGenerateAdviceIfAvailable: - def _analysis(self): - return { - "repos_analyzed": 5, - "languages": {"Python": 50000}, - "position_scores": { - "backend": 70, - "frontend": 20, - "fullstack": 45, - "sre": 25, - "cloud": 15, - "missing_skills": [], - }, - } - - def test_llm_unavailable_returns_none(self): - """LLM が利用不可の場合 (None, False) を返すこと。""" - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(return_value=False) - - with patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=mock_llm, - ): - result = _run(_generate_advice_if_available(self._analysis())) - - assert result == (None, False) - - def test_llm_available_returns_advice(self): - """LLM が利用可能の場合、(アドバイス文字列, False) を返すこと。""" - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(return_value=True) - - with ( - patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=mock_llm, - ), - patch( - "app.services.intelligence.github_analysis_service.generate_learning_advice", - new_callable=AsyncMock, - return_value="学習アドバイスです", - ), - ): - result = _run(_generate_advice_if_available(self._analysis())) - - assert result == ("学習アドバイスです", False) - - def test_llm_retryable_error_returns_warning(self): - """LLM が想定内の RetryableError を送出した場合 (None, True) を返し例外を握りつぶすこと。""" - from app.services.tasks.exceptions import RetryableError - - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(side_effect=RetryableError("LLM 一時障害")) - - with patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=mock_llm, - ): - result = _run(_generate_advice_if_available(self._analysis())) - - assert result == (None, True) - - def test_llm_unexpected_exception_propagates(self): - """LLM が予期しない例外を送出した場合は再送出されること(プログラミングエラー検知のため)。""" - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) - - with patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=mock_llm, - ): - with pytest.raises(Exception, match="LLM クラッシュ"): - _run(_generate_advice_if_available(self._analysis())) - - def test_no_position_scores_returns_none(self): - """position_scores が None の場合 (None, False) を返すこと。""" - mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(return_value=True) - - with patch( - "app.services.intelligence.github_analysis_service.get_llm_client", - return_value=mock_llm, - ): - result = _run(_generate_advice_if_available({"repos_analyzed": 5})) - - assert result == (None, False) - - -# ── execute_task (ルーティングロジック) ─────────────────────────────────── - - -class TestExecuteTask: - def test_known_task_type_routes_to_correct_handler(self, db_session: Session): - """GITHUB_ANALYSIS が _run_github_analysis に正しくディスパッチされ、 - 他のハンドラ関数は呼ばれないことを確認する。""" - with ( - patch("app.services.tasks.worker.SessionLocal", return_value=db_session), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - ) as mock_gh, - patch( - "app.services.tasks.worker._run_blog_summarize", - new_callable=AsyncMock, - ) as mock_blog, - patch( - "app.services.tasks.worker._run_career_analysis", - new_callable=AsyncMock, - ) as mock_career, - patch("app.services.tasks.worker._create_notification"), - ): - _run( - execute_task( - TaskType.GITHUB_ANALYSIS, - {"user_id": "test-user", "github_username": "u"}, - ) - ) - - mock_gh.assert_called_once() - mock_blog.assert_not_called() - mock_career.assert_not_called() - - def test_execute_task_marks_dead_letter_on_error(self, db_session: Session): - """予期しない例外が発生した場合(max_attempts=1)、_mark_dead_letter が - 呼ばれ例外が再 raise されること。""" - mock_db = MagicMock() - mock_session_local = MagicMock(return_value=mock_db) - - with ( - patch("app.services.tasks.worker.SessionLocal", mock_session_local), - patch( - "app.services.tasks.worker._run_github_analysis", - new_callable=AsyncMock, - side_effect=RuntimeError("予期しないクラッシュ"), - ), - patch("app.services.tasks.worker._mark_dead_letter") as mock_mark_dead_letter, - patch("app.services.tasks.worker._create_notification"), - ): - with pytest.raises(RuntimeError, match="予期しないクラッシュ"): - _run( - execute_task( - TaskType.GITHUB_ANALYSIS, - {"user_id": "test-user-id", "github_username": "u"}, - ) - ) - - mock_mark_dead_letter.assert_called_once() - call_args = mock_mark_dead_letter.call_args - assert call_args.args == ( - mock_db, - TaskType.GITHUB_ANALYSIS, - {"user_id": "test-user-id", "github_username": "u"}, - ) - assert isinstance(call_args.kwargs.get("error"), RuntimeError) - - def test_execute_task_creates_notification_on_success(self, db_session: Session): - """タスク成功時に _create_notification が呼ばれること。""" - mock_db = MagicMock() - mock_session_local = MagicMock(return_value=mock_db) - - with ( - patch("app.services.tasks.worker.SessionLocal", mock_session_local), - patch( - "app.services.tasks.worker._run_blog_summarize", - new_callable=AsyncMock, - return_value=None, - ), - patch("app.services.tasks.worker._create_notification") as mock_notify, - ): - _run( - execute_task( - TaskType.BLOG_SUMMARIZE, - {"user_id": "notif-test-user"}, - ) - ) - - mock_notify.assert_called_once_with( - mock_db, TaskType.BLOG_SUMMARIZE, "notif-test-user", "completed" - ) - - -# ── _safe_rollback ──────────────────────────────────────────────────────── - - -class TestSafeRollback: - def test_rollback_after_failed_commit_restores_session(self, db_session: Session): - """DB commit 失敗後に _safe_rollback を呼ぶと、セッションが再利用可能になること。""" - user = UserRepository(db_session).create( - "rollback-test-user", hashed_password=None, email="rollback@test.com" - ) - cache = BlogSummaryCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - # コミット失敗をシミュレートしてセッションを PendingRollback 状態にする - db_session.execute.__self__ if hasattr(db_session.execute, "__self__") else None - db_session.rollback() # まず手動でロールバックして dirty 状態を作る - - # _safe_rollback は例外を上げないこと - _safe_rollback(db_session) - - # ロールバック後にセッションが再利用可能であること - cache.status = "dead_letter" - db_session.commit() - db_session.refresh(cache) - assert cache.status == "dead_letter" - - def test_safe_rollback_suppresses_exception(self): - """rollback() が例外を送出しても _safe_rollback は例外を外に漏らさないこと。""" - mock_db = MagicMock() - mock_db.rollback.side_effect = Exception("DB 接続断") - - # 例外が外に漏れないこと - _safe_rollback(mock_db) - mock_db.rollback.assert_called_once() - - -# ── _mark_dead_letter (career_analysis) ────────────────────────────────── - - -class TestMarkDeadLetterCareerAnalysis: - def test_mark_dead_letter_career_analysis(self, db_session: Session): - """_mark_dead_letter が CareerAnalysis のステータスを dead_letter に更新すること。""" - user = UserRepository(db_session).create( - "mf-career-user", hashed_password=None, email="mfcareer@test.com" - ) - analysis = CareerAnalysis( - user_id=user.id, - version=1, - target_position="SRE", - status="processing", - ) - db_session.add(analysis) - db_session.commit() - - _mark_dead_letter( - db_session, - TaskType.CAREER_ANALYSIS, - {"user_id": user.id, "record_id": analysis.id}, - ) - - db_session.refresh(analysis) - assert analysis.status == "dead_letter" - assert analysis.error_message == "予期しないエラーが発生しました" - - def test_mark_dead_letter_does_not_overwrite_completed(self, db_session: Session): - """completed 済みのレコードは _mark_dead_letter で上書きされないこと。""" - user = UserRepository(db_session).create( - "mf-career-done", hashed_password=None, email="mfcareerdone@test.com" - ) - analysis = CareerAnalysis( - user_id=user.id, - version=1, - target_position="SRE", - status="completed", - ) - db_session.add(analysis) - db_session.commit() - - _mark_dead_letter( - db_session, - TaskType.CAREER_ANALYSIS, - {"user_id": user.id, "record_id": analysis.id}, - ) - - db_session.refresh(analysis) - assert analysis.status == "completed" diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts index 631aae9f..d095fc69 100644 --- a/frontend/src/api/paths.ts +++ b/frontend/src/api/paths.ts @@ -34,9 +34,9 @@ export const PATHS = { careerAnalysis: { base: "/api/career-analysis/", generate: "/api/career-analysis/generate", - byId: (id: number | string) => `/api/career-analysis/${id}`, - status: (id: number | string) => `/api/career-analysis/${id}/status`, - retry: (id: number | string) => `/api/career-analysis/${id}/retry`, + byId: (id: number) => `/api/career-analysis/${id}`, + status: (id: number) => `/api/career-analysis/${id}/status`, + retry: (id: number) => `/api/career-analysis/${id}/retry`, }, intelligence: { analyze: "/api/intelligence/analyze", @@ -71,9 +71,9 @@ export const PATHS = { aiResume: { generate: "/api/ai-resume/generate", snapshots: "/api/ai-resume/snapshots", - snapshotById: (id: number | string) => `/api/ai-resume/snapshots/${id}`, - snapshotFinalize: (id: number | string) => `/api/ai-resume/snapshots/${id}/finalize`, - snapshotPdf: (id: number | string) => `/api/ai-resume/snapshots/${id}/pdf`, - snapshotMarkdown: (id: number | string) => `/api/ai-resume/snapshots/${id}/markdown`, + snapshotById: (id: number) => `/api/ai-resume/snapshots/${id}`, + snapshotFinalize: (id: number) => `/api/ai-resume/snapshots/${id}/finalize`, + snapshotPdf: (id: number) => `/api/ai-resume/snapshots/${id}/pdf`, + snapshotMarkdown: (id: number) => `/api/ai-resume/snapshots/${id}/markdown`, }, } as const;