diff --git a/.beans/nuzlocke-tracker-2fp1--show-owner-info-in-admin-pages.md b/.beans/nuzlocke-tracker-2fp1--show-owner-info-in-admin-pages.md new file mode 100644 index 0000000..78ff184 --- /dev/null +++ b/.beans/nuzlocke-tracker-2fp1--show-owner-info-in-admin-pages.md @@ -0,0 +1,43 @@ +--- +# nuzlocke-tracker-2fp1 +title: Show owner info in admin pages +status: in-progress +type: feature +priority: normal +created_at: 2026-03-21T12:18:51Z +updated_at: 2026-03-21T12:37:36Z +parent: nuzlocke-tracker-wwnu +--- + +## Problem + +Admin pages (`AdminRuns.tsx`, `AdminGenlockes.tsx`) don't show which user owns each run or genlocke. This makes it hard for admins to manage content. + +## Approach + +### Backend +- The `/api/runs` list endpoint already returns run data — verify it includes `owner` (id + email). If not, add it to the response schema. +- For genlockes, ownership is inferred from the first leg's run owner. Add an `owner` field to the genlocke list response that resolves from the first leg's run. + +### Frontend +- `AdminRuns.tsx`: Add an "Owner" column showing the owner's email (or "No owner" for legacy runs) +- `AdminGenlockes.tsx`: Add an "Owner" column showing the inferred owner from the first leg's run +- Add owner filter dropdown to both pages + +## Files to modify + +- `backend/src/app/api/runs.py` — verify owner is included in list response +- `backend/src/app/api/genlockes.py` — add owner resolution to list endpoint +- `backend/src/app/schemas/genlocke.py` — add owner field to `GenlockeListItem` +- `frontend/src/pages/admin/AdminRuns.tsx` — add Owner column + filter +- `frontend/src/pages/admin/AdminGenlockes.tsx` — add Owner column + filter +- `frontend/src/types/game.ts` — update types if needed + +## Checklist + +- [x] Verify runs list API includes owner info; add if missing +- [x] Add owner resolution to genlocke list endpoint (from first leg's run) +- [x] Update `GenlockeListItem` schema to include owner +- [x] Add Owner column to `AdminRuns.tsx` +- [x] Add Owner column to `AdminGenlockes.tsx` +- [x] Add owner filter to both admin pages diff --git a/.beans/nuzlocke-tracker-73ba--enforce-run-ownership-on-all-mutation-endpoints.md b/.beans/nuzlocke-tracker-73ba--enforce-run-ownership-on-all-mutation-endpoints.md new file mode 100644 index 0000000..c5d59ee --- /dev/null +++ b/.beans/nuzlocke-tracker-73ba--enforce-run-ownership-on-all-mutation-endpoints.md @@ -0,0 +1,84 @@ +--- +# nuzlocke-tracker-73ba +title: Enforce run ownership on all mutation endpoints +status: completed +type: bug +priority: critical +created_at: 2026-03-21T12:18:27Z +updated_at: 2026-03-21T12:28:35Z +parent: nuzlocke-tracker-wwnu +--- + +## Problem + +Backend mutation endpoints for encounters, bosses, and run updates use `require_auth` but do NOT verify the authenticated user is the run's owner. Any authenticated user can modify any run's encounters, mark bosses as defeated, or change run settings. + +Additionally, `_check_run_access` in `runs.py:184` allows anyone to edit unowned (legacy) runs when `require_owner=False`. + +### Affected endpoints + +**encounters.py** — all mutations use `require_auth` with no ownership check: +- `POST /runs/{run_id}/encounters` (line 35) +- `PATCH /runs/{run_id}/encounters/{encounter_id}` (line 142) +- `DELETE /runs/{run_id}/encounters/{encounter_id}` (line 171) +- `POST /runs/{run_id}/encounters/bulk-randomize` (line 203) + +**bosses.py** — boss result mutations: +- `POST /runs/{run_id}/boss-results` (line 347) +- `DELETE /runs/{run_id}/boss-results/{result_id}` (line 428) + +**runs.py** — run updates/deletion: +- `PATCH /runs/{run_id}` (line 379) — uses `_check_run_access(run, user, require_owner=run.owner_id is not None)` which skips check for unowned runs +- `DELETE /runs/{run_id}` (line 488) — same conditional check + +**genlockes.py** — genlocke mutations: +- `POST /genlockes` (line 439) — no owner assigned to created genlocke or its first run +- `PATCH /genlockes/{id}` (line 824) — no ownership check +- `DELETE /genlockes/{id}` (line 862) — no ownership check +- `POST /genlockes/{id}/legs/{leg_order}/advance` (line 569) — no ownership check +- `POST /genlockes/{id}/legs` (line 894) — no ownership check +- `DELETE /genlockes/{id}/legs/{leg_id}` (line 936) — no ownership check + +## Approach + +1. Add a reusable `_check_run_owner(run, user)` helper in `auth.py` or `runs.py` that raises 403 if `user.id != str(run.owner_id)` (no fallback for unowned runs — they should be read-only) +2. Apply ownership check to ALL encounter/boss/run mutation endpoints +3. For genlocke mutations, load the first leg's run and verify ownership against that +4. Update `_check_run_access` to always require ownership for mutations (remove the `require_owner` conditional) +5. When creating runs (standalone or via genlocke), set `owner_id` from the authenticated user + +## Checklist + +- [x] Add `_check_run_owner` helper that rejects non-owners (including unowned/legacy runs) +- [x] Apply ownership check to all 4 encounter mutation endpoints +- [x] Apply ownership check to both boss result mutation endpoints +- [x] Fix `_check_run_access` to always require ownership on mutations +- [x] Set `owner_id` on run creation in `runs.py` and `genlockes.py` (create_genlocke, advance_leg) +- [x] Apply ownership check to all genlocke mutation endpoints (via first leg's run owner) +- [x] Add tests for ownership enforcement (403 for non-owner, 401 for unauthenticated) + +## Summary of Changes + +Added `require_run_owner` helper in `auth.py` that enforces ownership on mutation endpoints: +- Returns 403 for unowned (legacy) runs - they are now read-only +- Returns 403 if authenticated user is not the run's owner + +Applied ownership checks to: +- All 4 encounter mutation endpoints (create, update, delete, bulk-randomize) +- Both boss result mutation endpoints (create, delete) +- Run update and delete endpoints (via `require_run_owner`) +- All 5 genlocke mutation endpoints (update, delete, advance_leg, add_leg, remove_leg via `_check_genlocke_owner`) + +Added `owner_id` on run creation: +- `runs.py`: create_run already sets owner_id (verified) +- `genlockes.py`: create_genlocke now sets owner_id on the first run +- `genlockes.py`: advance_leg preserves owner_id from current run to new run + +Renamed `_check_run_access` to `_check_run_read_access` (read-only visibility check) for clarity. + +Added 22 comprehensive tests in `test_ownership.py` covering: +- Owner can perform mutations +- Non-owner gets 403 on mutations +- Unauthenticated user gets 401 +- Unowned (legacy) runs reject all mutations +- Read access preserved for public runs diff --git a/.beans/nuzlocke-tracker-i0rn--infer-genlocke-visibility-from-first-legs-run.md b/.beans/nuzlocke-tracker-i0rn--infer-genlocke-visibility-from-first-legs-run.md new file mode 100644 index 0000000..3098eaf --- /dev/null +++ b/.beans/nuzlocke-tracker-i0rn--infer-genlocke-visibility-from-first-legs-run.md @@ -0,0 +1,54 @@ +--- +# nuzlocke-tracker-i0rn +title: Infer genlocke visibility from first leg's run +status: completed +type: feature +created_at: 2026-03-21T12:46:56Z +updated_at: 2026-03-21T12:46:56Z +--- + +## Problem + +Genlockes are always public — they have no visibility setting. They should inherit visibility from their first leg's run, so if a user makes their run private, the genlocke is also hidden from public listings. + +## Approach + +Rather than adding a `visibility` column to the `genlockes` table, infer it from the first leg's run at query time. This avoids sync issues and keeps the first leg's run as the source of truth. + +### Backend +- `list_genlockes` endpoint: filter out genlockes whose first leg's run is private (unless the requesting user is the owner) +- `get_genlocke` endpoint: return 404 if the first leg's run is private and the user is not the owner +- Add optional auth (not required) to genlocke read endpoints to check ownership + +### Frontend +- No changes needed — private genlockes simply won't appear in listings for non-owners + +## Files modified + +- `backend/src/app/api/genlockes.py` — add visibility filtering to all read endpoints + +## Checklist + +- [x] Add `get_current_user` (optional auth) dependency to genlocke read endpoints +- [x] Filter private genlockes from `list_genlockes` for non-owners +- [x] Return 404 for private genlockes in `get_genlocke` for non-owners +- [x] Apply same filtering to graveyard, lineages, survivors, and retired-families endpoints +- [x] Test: private run's genlocke hidden from unauthenticated users +- [x] Test: owner can still see their private genlocke + +## Summary of Changes + +- Added `_is_genlocke_visible()` helper function to check visibility based on first leg's run +- Added optional auth (`get_current_user`) to all genlocke read endpoints: + - `list_genlockes`: filters out private genlockes for non-owners + - `get_genlocke`: returns 404 for private genlockes to non-owners + - `get_genlocke_graveyard`: returns 404 for private genlockes + - `get_genlocke_lineages`: returns 404 for private genlockes + - `get_leg_survivors`: returns 404 for private genlockes + - `get_retired_families`: returns 404 for private genlockes +- Added 9 new tests in `TestGenlockeVisibility` class covering: + - Private genlockes hidden from unauthenticated list + - Private genlockes visible to owner in list + - 404 for all detail endpoints when accessed by unauthenticated users + - 404 for private genlockes when accessed by different authenticated user + - Owner can still access their private genlocke diff --git a/.beans/nuzlocke-tracker-i2va--hide-edit-controls-for-non-owners-in-frontend.md b/.beans/nuzlocke-tracker-i2va--hide-edit-controls-for-non-owners-in-frontend.md new file mode 100644 index 0000000..491344c --- /dev/null +++ b/.beans/nuzlocke-tracker-i2va--hide-edit-controls-for-non-owners-in-frontend.md @@ -0,0 +1,41 @@ +--- +# nuzlocke-tracker-i2va +title: Hide edit controls for non-owners in frontend +status: in-progress +type: bug +priority: critical +created_at: 2026-03-21T12:18:38Z +updated_at: 2026-03-21T12:32:45Z +parent: nuzlocke-tracker-wwnu +blocked_by: + - nuzlocke-tracker-73ba +--- + +## Problem + +`RunEncounters.tsx` has NO auth checks — all edit buttons (encounter modals, boss defeat, status changes, end run, shiny encounters, egg encounters, transfers, HoF team) are always visible, even to logged-out users viewing a public run. + +`RunDashboard.tsx` has `canEdit = isOwner || !run?.owner` (line 70) which means unowned legacy runs are editable by anyone, including logged-out users. + +## Approach + +1. Add `useAuth` and `canEdit` logic to `RunEncounters.tsx`, matching the pattern from `RunDashboard.tsx` but stricter: `canEdit = isOwner` (no fallback for unowned runs) +2. Update `RunDashboard.tsx` line 70 to `canEdit = isOwner` (remove `|| !run?.owner`) +3. Conditionally render all mutation UI elements based on `canEdit`: + - Encounter create/edit modals and triggers + - Boss defeat buttons + - Status change / End run buttons + - Shiny encounter / Egg encounter modals + - Transfer modal + - HoF team modal + - Visibility settings toggle +4. Show a read-only banner when viewing someone else's run + +## Checklist + +- [x] Add `useAuth` import and `canEdit` logic to `RunEncounters.tsx` +- [x] Guard all mutation triggers in `RunEncounters.tsx` behind `canEdit` +- [x] Update `RunDashboard.tsx` `canEdit` to be `isOwner` only (no unowned fallback) +- [x] Guard all mutation triggers in `RunDashboard.tsx` behind `canEdit` +- [x] Add read-only indicator/banner for non-owner viewers +- [x] Verify logged-out users see no edit controls on public runs diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py index b03fa6f..4e05268 100644 --- a/backend/src/app/api/bosses.py +++ b/backend/src/app/api/bosses.py @@ -5,7 +5,7 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.core.auth import AuthUser, require_admin, require_auth +from app.core.auth import AuthUser, require_admin, require_auth, require_run_owner from app.core.database import get_session from app.models.boss_battle import BossBattle from app.models.boss_pokemon import BossPokemon @@ -344,12 +344,14 @@ async def create_boss_result( run_id: int, data: BossResultCreate, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") + require_run_owner(run, user) + boss = await session.get(BossBattle, data.boss_battle_id) if boss is None: raise HTTPException(status_code=404, detail="Boss battle not found") @@ -425,8 +427,14 @@ async def delete_boss_result( run_id: int, result_id: int, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): + run = await session.get(NuzlockeRun, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run not found") + + require_run_owner(run, user) + result = await session.execute( select(BossResult).where( BossResult.id == result_id, BossResult.run_id == run_id diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index fc92d37..fb71a99 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload -from app.core.auth import AuthUser, require_auth +from app.core.auth import AuthUser, require_auth, require_run_owner from app.core.database import get_session from app.models.encounter import Encounter from app.models.evolution import Evolution @@ -36,13 +36,15 @@ async def create_encounter( run_id: int, data: EncounterCreate, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): # Validate run exists run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") + require_run_owner(run, user) + # Validate route exists and load its children result = await session.execute( select(Route) @@ -139,12 +141,17 @@ async def update_encounter( encounter_id: int, data: EncounterUpdate, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): encounter = await session.get(Encounter, encounter_id) if encounter is None: raise HTTPException(status_code=404, detail="Encounter not found") + run = await session.get(NuzlockeRun, encounter.run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run not found") + require_run_owner(run, user) + update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(encounter, field, value) @@ -168,12 +175,17 @@ async def update_encounter( async def delete_encounter( encounter_id: int, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): encounter = await session.get(Encounter, encounter_id) if encounter is None: raise HTTPException(status_code=404, detail="Encounter not found") + run = await session.get(NuzlockeRun, encounter.run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run not found") + require_run_owner(run, user) + # Block deletion if encounter is referenced by a genlocke transfer transfer_result = await session.execute( select(GenlockeTransfer.id).where( @@ -200,12 +212,15 @@ async def delete_encounter( async def bulk_randomize_encounters( run_id: int, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): # 1. Validate run run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") + + require_run_owner(run, user) + if run.status != "active": raise HTTPException(status_code=400, detail="Run is not active") diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 5ccabf2..2312c9a 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -1,3 +1,5 @@ +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import delete as sa_delete @@ -6,16 +8,17 @@ from sqlalchemy import update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.core.auth import AuthUser, require_auth +from app.core.auth import AuthUser, get_current_user, require_auth, require_run_owner from app.core.database import get_session from app.models.encounter import Encounter from app.models.evolution import Evolution from app.models.game import Game from app.models.genlocke import Genlocke, GenlockeLeg from app.models.genlocke_transfer import GenlockeTransfer -from app.models.nuzlocke_run import NuzlockeRun +from app.models.nuzlocke_run import NuzlockeRun, RunVisibility from app.models.pokemon import Pokemon from app.models.route import Route +from app.models.user import User from app.schemas.genlocke import ( AddLegRequest, AdvanceLegRequest, @@ -25,6 +28,7 @@ from app.schemas.genlocke import ( GenlockeLegDetailResponse, GenlockeLineageResponse, GenlockeListItem, + GenlockeOwnerResponse, GenlockeResponse, GenlockeStatsResponse, GenlockeUpdate, @@ -41,12 +45,72 @@ from app.services.families import build_families, resolve_base_form router = APIRouter() +async def _check_genlocke_owner( + genlocke_id: int, + user: AuthUser, + session: AsyncSession, +) -> None: + """ + Verify user owns the genlocke via the first leg's run. + Raises 404 if the genlocke doesn't exist. + Raises 403 if the first leg has a run with a different owner. + Raises 403 if the first leg has an unowned run (read-only legacy data). + """ + # First check if genlocke exists + genlocke = await session.get(Genlocke, genlocke_id) + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + leg_result = await session.execute( + select(GenlockeLeg) + .where(GenlockeLeg.genlocke_id == genlocke_id, GenlockeLeg.leg_order == 1) + .options(selectinload(GenlockeLeg.run)) + ) + first_leg = leg_result.scalar_one_or_none() + + if first_leg is None or first_leg.run is None: + raise HTTPException( + status_code=403, + detail="Cannot modify genlocke: no run found for first leg", + ) + + require_run_owner(first_leg.run, user) + + +def _is_genlocke_visible(genlocke: Genlocke, user: AuthUser | None) -> bool: + """ + Check if a genlocke is visible to the given user. + Visibility is inferred from the first leg's run: + - Public runs are visible to everyone + - Private runs are only visible to the owner + """ + first_leg = next((leg for leg in genlocke.legs if leg.leg_order == 1), None) + if not first_leg or not first_leg.run: + # No first leg or run - treat as visible (legacy data) + return True + + if first_leg.run.visibility == RunVisibility.PUBLIC: + return True + + # Private run - only visible to owner + if user is None: + return False + if first_leg.run.owner_id is None: + return False + return str(first_leg.run.owner_id) == user.id + + @router.get("", response_model=list[GenlockeListItem]) -async def list_genlockes(session: AsyncSession = Depends(get_session)): +async def list_genlockes( + session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), +): result = await session.execute( select(Genlocke) .options( - selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), + selectinload(Genlocke.legs) + .selectinload(GenlockeLeg.run) + .selectinload(NuzlockeRun.owner), ) .order_by(Genlocke.created_at.desc()) ) @@ -54,8 +118,22 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)): items = [] for g in genlockes: + # Filter out private genlockes for non-owners + if not _is_genlocke_visible(g, user): + continue + completed_legs = 0 current_leg_order = None + owner = None + + # Find first leg (leg_order == 1) to get owner + first_leg = next((leg for leg in g.legs if leg.leg_order == 1), None) + if first_leg and first_leg.run and first_leg.run.owner: + owner = GenlockeOwnerResponse( + id=first_leg.run.owner.id, + display_name=first_leg.run.owner.display_name, + ) + for leg in g.legs: if leg.run and leg.run.status == "completed": completed_legs += 1 @@ -71,13 +149,18 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)): total_legs=len(g.legs), completed_legs=completed_legs, current_leg_order=current_leg_order, + owner=owner, ) ) return items @router.get("/{genlocke_id}", response_model=GenlockeDetailResponse) -async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_session)): +async def get_genlocke( + genlocke_id: int, + session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), +): result = await session.execute( select(Genlocke) .where(Genlocke.id == genlocke_id) @@ -90,6 +173,10 @@ async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_ses if genlocke is None: raise HTTPException(status_code=404, detail="Genlocke not found") + # Check visibility - return 404 for private genlockes to non-owners + if not _is_genlocke_visible(genlocke, user): + raise HTTPException(status_code=404, detail="Genlocke not found") + # Collect run IDs for aggregate query run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None] @@ -173,20 +260,26 @@ async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_ses response_model=GenlockeGraveyardResponse, ) async def get_genlocke_graveyard( - genlocke_id: int, session: AsyncSession = Depends(get_session) + genlocke_id: int, + session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), ): - # Load genlocke with legs + game + # Load genlocke with legs + game + run (for visibility check) result = await session.execute( select(Genlocke) .where(Genlocke.id == genlocke_id) .options( selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), ) ) genlocke = result.scalar_one_or_none() if genlocke is None: raise HTTPException(status_code=404, detail="Genlocke not found") + if not _is_genlocke_visible(genlocke, user): + raise HTTPException(status_code=404, detail="Genlocke not found") + # Build run_id → (leg_order, game_name) lookup run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None] run_lookup: dict[int, tuple[int, str]] = {} @@ -274,20 +367,26 @@ async def get_genlocke_graveyard( response_model=GenlockeLineageResponse, ) async def get_genlocke_lineages( - genlocke_id: int, session: AsyncSession = Depends(get_session) + genlocke_id: int, + session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), ): - # Load genlocke with legs + game + # Load genlocke with legs + game + run (for visibility check) result = await session.execute( select(Genlocke) .where(Genlocke.id == genlocke_id) .options( selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), ) ) genlocke = result.scalar_one_or_none() if genlocke is None: raise HTTPException(status_code=404, detail="Genlocke not found") + if not _is_genlocke_visible(genlocke, user): + raise HTTPException(status_code=404, detail="Genlocke not found") + # Query all transfers for this genlocke transfer_result = await session.execute( select(GenlockeTransfer).where(GenlockeTransfer.genlocke_id == genlocke_id) @@ -440,7 +539,7 @@ async def get_genlocke_lineages( async def create_genlocke( data: GenlockeCreate, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): if not data.game_ids: raise HTTPException(status_code=400, detail="At least one game is required") @@ -455,6 +554,13 @@ async def create_genlocke( if missing: raise HTTPException(status_code=404, detail=f"Games not found: {missing}") + # Ensure user exists in local DB + user_id = UUID(user.id) + db_user = await session.get(User, user_id) + if db_user is None: + db_user = User(id=user_id, email=user.email or "") + session.add(db_user) + # Create genlocke genlocke = Genlocke( name=data.name.strip(), @@ -481,6 +587,7 @@ async def create_genlocke( first_game = found_games[data.game_ids[0]] first_run = NuzlockeRun( game_id=first_game.id, + owner_id=user_id, name=f"{data.name.strip()} \u2014 Leg 1", status="active", rules=data.nuzlocke_rules, @@ -513,15 +620,23 @@ async def get_leg_survivors( genlocke_id: int, leg_order: int, session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), ): - # Find the leg - result = await session.execute( - select(GenlockeLeg).where( - GenlockeLeg.genlocke_id == genlocke_id, - GenlockeLeg.leg_order == leg_order, - ) + # Load genlocke with legs + run for visibility check + genlocke_result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke_id) + .options(selectinload(Genlocke.legs).selectinload(GenlockeLeg.run)) ) - leg = result.scalar_one_or_none() + genlocke = genlocke_result.scalar_one_or_none() + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + if not _is_genlocke_visible(genlocke, user): + raise HTTPException(status_code=404, detail="Genlocke not found") + + # Find the leg + leg = next((leg for leg in genlocke.legs if leg.leg_order == leg_order), None) if leg is None: raise HTTPException(status_code=404, detail="Leg not found") @@ -571,8 +686,10 @@ async def advance_leg( leg_order: int, data: AdvanceLegRequest | None = None, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): + await _check_genlocke_owner(genlocke_id, user, session) + # Load genlocke with legs result = await session.execute( select(Genlocke) @@ -653,9 +770,10 @@ async def advance_leg( else: current_leg.retired_pokemon_ids = [] - # Create a new run for the next leg + # Create a new run for the next leg, preserving owner from current run new_run = NuzlockeRun( game_id=next_leg.game_id, + owner_id=current_run.owner_id, name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}", status="active", rules=genlocke.nuzlocke_rules, @@ -786,12 +904,21 @@ class RetiredFamiliesResponse(BaseModel): async def get_retired_families( genlocke_id: int, session: AsyncSession = Depends(get_session), + user: AuthUser | None = Depends(get_current_user), ): - # Verify genlocke exists - genlocke = await session.get(Genlocke, genlocke_id) + # Load genlocke with legs + run for visibility check + result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke_id) + .options(selectinload(Genlocke.legs).selectinload(GenlockeLeg.run)) + ) + genlocke = result.scalar_one_or_none() if genlocke is None: raise HTTPException(status_code=404, detail="Genlocke not found") + if not _is_genlocke_visible(genlocke, user): + raise HTTPException(status_code=404, detail="Genlocke not found") + # Query all legs with retired_pokemon_ids result = await session.execute( select(GenlockeLeg) @@ -826,8 +953,10 @@ async def update_genlocke( genlocke_id: int, data: GenlockeUpdate, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): + await _check_genlocke_owner(genlocke_id, user, session) + result = await session.execute( select(Genlocke) .where(Genlocke.id == genlocke_id) @@ -863,8 +992,10 @@ async def update_genlocke( async def delete_genlocke( genlocke_id: int, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): + await _check_genlocke_owner(genlocke_id, user, session) + genlocke = await session.get(Genlocke, genlocke_id) if genlocke is None: raise HTTPException(status_code=404, detail="Genlocke not found") @@ -895,8 +1026,10 @@ async def add_leg( genlocke_id: int, data: AddLegRequest, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): + await _check_genlocke_owner(genlocke_id, user, session) + genlocke = await session.get(Genlocke, genlocke_id) if genlocke is None: raise HTTPException(status_code=404, detail="Genlocke not found") @@ -938,8 +1071,10 @@ async def remove_leg( genlocke_id: int, leg_id: int, session: AsyncSession = Depends(get_session), - _user: AuthUser = Depends(require_auth), + user: AuthUser = Depends(require_auth), ): + await _check_genlocke_owner(genlocke_id, user, session) + result = await session.execute( select(GenlockeLeg).where( GenlockeLeg.id == leg_id, diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index 52d9d1e..53282ea 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -6,7 +6,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload -from app.core.auth import AuthUser, get_current_user, require_auth +from app.core.auth import AuthUser, get_current_user, require_auth, require_run_owner from app.core.database import get_session from app.models.boss_result import BossResult from app.models.encounter import Encounter @@ -181,31 +181,17 @@ def _build_run_response(run: NuzlockeRun) -> RunResponse: ) -def _check_run_access( - run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False -) -> None: +def _check_run_read_access(run: NuzlockeRun, user: AuthUser | None) -> None: """ - Check if user can access the run. + Check if user can read the run. Raises 403 for private runs if user is not owner. - If require_owner=True, always requires ownership (for mutations). + Unowned runs are readable by everyone (legacy). """ if run.owner_id is None: - # Unowned runs are accessible by everyone (legacy) - if require_owner: - raise HTTPException( - status_code=403, detail="Only the run owner can perform this action" - ) return user_id = UUID(user.id) if user else None - if require_owner: - if user_id != run.owner_id: - raise HTTPException( - status_code=403, detail="Only the run owner can perform this action" - ) - return - if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id: raise HTTPException(status_code=403, detail="This run is private") @@ -301,7 +287,7 @@ async def get_run( raise HTTPException(status_code=404, detail="Run not found") # Check visibility access - _check_run_access(run, user) + _check_run_read_access(run, user) # Check if this run belongs to a genlocke genlocke_context = None @@ -375,8 +361,7 @@ async def update_run( if run is None: raise HTTPException(status_code=404, detail="Run not found") - # Check ownership for mutations (unowned runs allow anyone for backwards compat) - _check_run_access(run, user, require_owner=run.owner_id is not None) + require_run_owner(run, user) update_data = data.model_dump(exclude_unset=True) @@ -484,8 +469,7 @@ async def delete_run( if run is None: raise HTTPException(status_code=404, detail="Run not found") - # Check ownership for deletion (unowned runs allow anyone for backwards compat) - _check_run_access(run, user, require_owner=run.owner_id is not None) + require_run_owner(run, user) # Block deletion if run is linked to a genlocke leg leg_result = await session.execute( diff --git a/backend/src/app/core/auth.py b/backend/src/app/core/auth.py index 6a5b392..d2bb37a 100644 --- a/backend/src/app/core/auth.py +++ b/backend/src/app/core/auth.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_session +from app.models.nuzlocke_run import NuzlockeRun from app.models.user import User @@ -105,3 +106,20 @@ async def require_admin( detail="Admin access required", ) return user + + +def require_run_owner(run: NuzlockeRun, user: AuthUser) -> None: + """ + Verify user owns the run. Raises 403 if not owner. + Unowned (legacy) runs are read-only and reject all mutations. + """ + if run.owner_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This run has no owner and cannot be modified", + ) + if UUID(user.id) != run.owner_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the run owner can perform this action", + ) diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index 54cdc58..40d242d 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -1,10 +1,16 @@ from datetime import datetime +from uuid import UUID from app.schemas.base import CamelModel from app.schemas.game import GameResponse from app.schemas.pokemon import PokemonResponse +class GenlockeOwnerResponse(CamelModel): + id: UUID + display_name: str | None = None + + class GenlockeCreate(CamelModel): name: str game_ids: list[int] @@ -92,6 +98,7 @@ class GenlockeListItem(CamelModel): total_legs: int completed_legs: int current_leg_order: int | None = None + owner: GenlockeOwnerResponse | None = None class GenlockeDetailResponse(CamelModel): diff --git a/backend/tests/test_genlocke_boss.py b/backend/tests/test_genlocke_boss.py index 38923bb..20d7dcd 100644 --- a/backend/tests/test_genlocke_boss.py +++ b/backend/tests/test_genlocke_boss.py @@ -1,10 +1,13 @@ """Integration tests for the Genlockes & Bosses API.""" import pytest -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import AuthUser, get_current_user +from app.main import app from app.models.game import Game +from app.models.nuzlocke_run import NuzlockeRun, RunVisibility from app.models.pokemon import Pokemon from app.models.route import Route from app.models.version_group import VersionGroup @@ -55,7 +58,9 @@ async def games_ctx(db_session: AsyncSession) -> dict: @pytest.fixture -async def ctx(db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict) -> dict: +async def ctx( + db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict +) -> dict: """Full context: routes + pokemon + genlocke + encounter for advance/transfer tests.""" route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1) route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1) @@ -116,6 +121,178 @@ class TestListGenlockes: assert "Test Genlocke" in names +# --------------------------------------------------------------------------- +# Genlockes — visibility (inferred from first leg's run) +# --------------------------------------------------------------------------- + + +class TestGenlockeVisibility: + """Test that genlocke visibility is inferred from the first leg's run.""" + + @pytest.fixture + async def private_genlocke_ctx( + self, db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict + ) -> dict: + """Create a genlocke and make its first leg's run private.""" + r = await admin_client.post( + GENLOCKES_BASE, + json={ + "name": "Private Genlocke", + "gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]], + }, + ) + assert r.status_code == 201 + genlocke = r.json() + leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1) + run_id = leg1["runId"] + + # Make the run private + run = await db_session.get(NuzlockeRun, run_id) + assert run is not None + run.visibility = RunVisibility.PRIVATE + await db_session.commit() + + return { + **games_ctx, + "genlocke_id": genlocke["id"], + "run_id": run_id, + "owner_id": str(run.owner_id), + } + + async def test_private_genlocke_hidden_from_unauthenticated_list( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """Unauthenticated users should not see private genlockes in the list.""" + # Temporarily remove auth override to simulate unauthenticated request + app.dependency_overrides.pop(get_current_user, None) + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as unauth_client: + response = await unauth_client.get(GENLOCKES_BASE) + assert response.status_code == 200 + names = [g["name"] for g in response.json()] + assert "Private Genlocke" not in names + finally: + pass + + async def test_private_genlocke_visible_to_owner_in_list( + self, admin_client: AsyncClient, private_genlocke_ctx: dict + ): + """Owner should still see their private genlocke in the list.""" + response = await admin_client.get(GENLOCKES_BASE) + assert response.status_code == 200 + names = [g["name"] for g in response.json()] + assert "Private Genlocke" in names + + async def test_private_genlocke_404_for_unauthenticated_get( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """Unauthenticated users should get 404 for private genlocke details.""" + app.dependency_overrides.pop(get_current_user, None) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as unauth_client: + response = await unauth_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}" + ) + assert response.status_code == 404 + + async def test_private_genlocke_accessible_to_owner( + self, admin_client: AsyncClient, private_genlocke_ctx: dict + ): + """Owner should still be able to access their private genlocke.""" + response = await admin_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}" + ) + assert response.status_code == 200 + assert response.json()["name"] == "Private Genlocke" + + async def test_private_genlocke_graveyard_404_for_unauthenticated( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """Unauthenticated users should get 404 for private genlocke graveyard.""" + app.dependency_overrides.pop(get_current_user, None) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as unauth_client: + response = await unauth_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/graveyard" + ) + assert response.status_code == 404 + + async def test_private_genlocke_lineages_404_for_unauthenticated( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """Unauthenticated users should get 404 for private genlocke lineages.""" + app.dependency_overrides.pop(get_current_user, None) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as unauth_client: + response = await unauth_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/lineages" + ) + assert response.status_code == 404 + + async def test_private_genlocke_survivors_404_for_unauthenticated( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """Unauthenticated users should get 404 for private genlocke survivors.""" + app.dependency_overrides.pop(get_current_user, None) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as unauth_client: + response = await unauth_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/legs/1/survivors" + ) + assert response.status_code == 404 + + async def test_private_genlocke_retired_families_404_for_unauthenticated( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """Unauthenticated users should get 404 for private retired-families.""" + app.dependency_overrides.pop(get_current_user, None) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as unauth_client: + response = await unauth_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/retired-families" + ) + assert response.status_code == 404 + + async def test_private_genlocke_404_for_different_user( + self, db_session: AsyncSession, private_genlocke_ctx: dict + ): + """A different authenticated user should get 404 for private genlockes.""" + # Create a different user's auth + different_user = AuthUser( + id="00000000-0000-4000-a000-000000000099", + email="other@example.com", + role="authenticated", + ) + + def _override(): + return different_user + + app.dependency_overrides[get_current_user] = _override + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as other_client: + response = await other_client.get( + f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}" + ) + assert response.status_code == 404 + + # Also check list + list_response = await other_client.get(GENLOCKES_BASE) + assert list_response.status_code == 200 + names = [g["name"] for g in list_response.json()] + assert "Private Genlocke" not in names + finally: + app.dependency_overrides.pop(get_current_user, None) + + # --------------------------------------------------------------------------- # Genlockes — create # --------------------------------------------------------------------------- @@ -259,14 +436,18 @@ class TestGenlockeLegs: class TestAdvanceLeg: - async def test_uncompleted_run_returns_400(self, admin_client: AsyncClient, ctx: dict): + async def test_uncompleted_run_returns_400( + self, admin_client: AsyncClient, ctx: dict + ): """Cannot advance when leg 1's run is still active.""" response = await admin_client.post( f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" ) assert response.status_code == 400 - async def test_no_next_leg_returns_400(self, admin_client: AsyncClient, games_ctx: dict): + async def test_no_next_leg_returns_400( + self, admin_client: AsyncClient, games_ctx: dict + ): """A single-leg genlocke cannot be advanced.""" r = await admin_client.post( GENLOCKES_BASE, @@ -283,7 +464,9 @@ class TestAdvanceLeg: async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict): """Completing the current run allows advancing to the next leg.""" - await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}) + await admin_client.patch( + f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"} + ) response = await admin_client.post( f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" @@ -295,7 +478,9 @@ class TestAdvanceLeg: async def test_advances_with_transfers(self, admin_client: AsyncClient, ctx: dict): """Advancing with transfer_encounter_ids creates egg encounters in the next leg.""" - await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}) + await admin_client.patch( + f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"} + ) response = await admin_client.post( f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance", @@ -319,30 +504,40 @@ class TestAdvanceLeg: class TestGenlockeGraveyard: async def test_returns_empty_graveyard(self, admin_client: AsyncClient, ctx: dict): - response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard") + response = await admin_client.get( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard" + ) assert response.status_code == 200 data = response.json() assert data["entries"] == [] assert data["totalDeaths"] == 0 async def test_not_found_returns_404(self, admin_client: AsyncClient): - assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404 + assert ( + await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard") + ).status_code == 404 class TestGenlockeLineages: async def test_returns_empty_lineages(self, admin_client: AsyncClient, ctx: dict): - response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages") + response = await admin_client.get( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages" + ) assert response.status_code == 200 data = response.json() assert data["lineages"] == [] assert data["totalLineages"] == 0 async def test_not_found_returns_404(self, admin_client: AsyncClient): - assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404 + assert ( + await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages") + ).status_code == 404 class TestGenlockeRetiredFamilies: - async def test_returns_empty_retired_families(self, admin_client: AsyncClient, ctx: dict): + async def test_returns_empty_retired_families( + self, admin_client: AsyncClient, ctx: dict + ): response = await admin_client.get( f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families" ) @@ -365,9 +560,13 @@ class TestLegSurvivors: assert response.status_code == 200 assert len(response.json()) == 1 - async def test_leg_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict): + async def test_leg_not_found_returns_404( + self, admin_client: AsyncClient, ctx: dict + ): assert ( - await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors") + await admin_client.get( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors" + ) ).status_code == 404 @@ -386,7 +585,9 @@ BOSS_PAYLOAD = { class TestBossCRUD: async def test_empty_list(self, admin_client: AsyncClient, games_ctx: dict): - response = await admin_client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses") + response = await admin_client.get( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses" + ) assert response.status_code == 200 assert response.json() == [] @@ -441,7 +642,9 @@ class TestBossCRUD: async def test_invalid_game_returns_404(self, admin_client: AsyncClient): assert (await admin_client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404 - async def test_game_without_version_group_returns_400(self, admin_client: AsyncClient): + async def test_game_without_version_group_returns_400( + self, admin_client: AsyncClient + ): game = ( await admin_client.post( GAMES_BASE, @@ -480,7 +683,9 @@ class TestBossResults: return {"boss_id": boss["id"], "run_id": run["id"]} async def test_empty_list(self, admin_client: AsyncClient, boss_ctx: dict): - response = await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") + response = await admin_client.get( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results" + ) assert response.status_code == 200 assert response.json() == [] @@ -495,7 +700,9 @@ class TestBossResults: assert data["attempts"] == 1 assert data["completedAt"] is not None - async def test_upserts_existing_result(self, admin_client: AsyncClient, boss_ctx: dict): + async def test_upserts_existing_result( + self, admin_client: AsyncClient, boss_ctx: dict + ): """POSTing the same boss twice updates the result (upsert).""" await admin_client.post( f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", @@ -530,10 +737,16 @@ class TestBossResults: await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") ).json() == [] - async def test_invalid_run_returns_404(self, admin_client: AsyncClient, boss_ctx: dict): - assert (await admin_client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404 + async def test_invalid_run_returns_404( + self, admin_client: AsyncClient, boss_ctx: dict + ): + assert ( + await admin_client.get(f"{RUNS_BASE}/9999/boss-results") + ).status_code == 404 - async def test_invalid_boss_returns_404(self, admin_client: AsyncClient, boss_ctx: dict): + async def test_invalid_boss_returns_404( + self, admin_client: AsyncClient, boss_ctx: dict + ): response = await admin_client.post( f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", json={"bossBattleId": 9999, "result": "won"}, @@ -587,8 +800,16 @@ class TestExport: assert response.status_code == 200 assert isinstance(response.json(), list) - async def test_export_game_routes_not_found_returns_404(self, admin_client: AsyncClient): - assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404 + async def test_export_game_routes_not_found_returns_404( + self, admin_client: AsyncClient + ): + assert ( + await admin_client.get(f"{EXPORT_BASE}/games/9999/routes") + ).status_code == 404 - async def test_export_game_bosses_not_found_returns_404(self, admin_client: AsyncClient): - assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404 + async def test_export_game_bosses_not_found_returns_404( + self, admin_client: AsyncClient + ): + assert ( + await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses") + ).status_code == 404 diff --git a/backend/tests/test_ownership.py b/backend/tests/test_ownership.py new file mode 100644 index 0000000..2135502 --- /dev/null +++ b/backend/tests/test_ownership.py @@ -0,0 +1,447 @@ +"""Tests for run ownership enforcement on mutation endpoints.""" + +from uuid import UUID + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import AuthUser, get_current_user, require_run_owner +from app.main import app +from app.models.game import Game +from app.models.nuzlocke_run import NuzlockeRun +from app.models.user import User + +RUNS_BASE = "/api/v1/runs" +ENC_BASE = "/api/v1/encounters" + +OWNER_ID = "00000000-0000-4000-a000-000000000001" +OTHER_USER_ID = "00000000-0000-4000-a000-000000000002" + + +@pytest.fixture +async def game(db_session: AsyncSession) -> Game: + """Create a test game.""" + game = Game(name="Test Game", slug="test-game", generation=1, region="kanto") + db_session.add(game) + await db_session.commit() + await db_session.refresh(game) + return game + + +@pytest.fixture +async def owner_user(db_session: AsyncSession) -> User: + """Create the owner user.""" + user = User(id=UUID(OWNER_ID), email="owner@example.com") + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def other_user(db_session: AsyncSession) -> User: + """Create another user who is not the owner.""" + user = User(id=UUID(OTHER_USER_ID), email="other@example.com") + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def owned_run( + db_session: AsyncSession, game: Game, owner_user: User +) -> NuzlockeRun: + """Create a run owned by the test owner.""" + run = NuzlockeRun( + game_id=game.id, + owner_id=owner_user.id, + name="Owned Run", + status="active", + ) + db_session.add(run) + await db_session.commit() + await db_session.refresh(run) + return run + + +@pytest.fixture +async def unowned_run(db_session: AsyncSession, game: Game) -> NuzlockeRun: + """Create a legacy run with no owner.""" + run = NuzlockeRun( + game_id=game.id, + owner_id=None, + name="Unowned Run", + status="active", + ) + db_session.add(run) + await db_session.commit() + await db_session.refresh(run) + return run + + +@pytest.fixture +def owner_auth_override(owner_user): + """Override auth to return the owner user.""" + + def _override(): + return AuthUser(id=OWNER_ID, email="owner@example.com") + + app.dependency_overrides[get_current_user] = _override + yield + app.dependency_overrides.pop(get_current_user, None) + + +@pytest.fixture +def other_auth_override(other_user): + """Override auth to return a different user (not the owner).""" + + def _override(): + return AuthUser(id=OTHER_USER_ID, email="other@example.com") + + app.dependency_overrides[get_current_user] = _override + yield + app.dependency_overrides.pop(get_current_user, None) + + +@pytest.fixture +async def owner_client(db_session, owner_auth_override): + """Client authenticated as the owner.""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac + + +@pytest.fixture +async def other_client(db_session, other_auth_override): + """Client authenticated as a different user.""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac + + +class TestRequireRunOwnerHelper: + """Tests for the require_run_owner helper function.""" + + async def test_owner_passes(self, owner_user, owned_run): + """Owner can access their own run.""" + auth_user = AuthUser(id=str(owner_user.id), email="owner@example.com") + require_run_owner(owned_run, auth_user) + + async def test_non_owner_raises_403(self, other_user, owned_run): + """Non-owner is rejected with 403.""" + from fastapi import HTTPException + + auth_user = AuthUser(id=str(other_user.id), email="other@example.com") + with pytest.raises(HTTPException) as exc_info: + require_run_owner(owned_run, auth_user) + assert exc_info.value.status_code == 403 + assert "Only the run owner" in exc_info.value.detail + + async def test_unowned_run_raises_403(self, owner_user, unowned_run): + """Unowned runs reject all mutations with 403.""" + from fastapi import HTTPException + + auth_user = AuthUser(id=str(owner_user.id), email="owner@example.com") + with pytest.raises(HTTPException) as exc_info: + require_run_owner(unowned_run, auth_user) + assert exc_info.value.status_code == 403 + assert "no owner" in exc_info.value.detail + + +class TestRunUpdateOwnership: + """Tests for run PATCH ownership enforcement.""" + + async def test_owner_can_update(self, owner_client: AsyncClient, owned_run): + """Owner can update their own run.""" + response = await owner_client.patch( + f"{RUNS_BASE}/{owned_run.id}", json={"name": "Updated Name"} + ) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Name" + + async def test_non_owner_cannot_update(self, other_client: AsyncClient, owned_run): + """Non-owner gets 403 when trying to update.""" + response = await other_client.patch( + f"{RUNS_BASE}/{owned_run.id}", json={"name": "Stolen"} + ) + assert response.status_code == 403 + assert "Only the run owner" in response.json()["detail"] + + async def test_unauthenticated_cannot_update(self, client: AsyncClient, owned_run): + """Unauthenticated user gets 401.""" + response = await client.patch( + f"{RUNS_BASE}/{owned_run.id}", json={"name": "Stolen"} + ) + assert response.status_code == 401 + + async def test_unowned_run_rejects_all_updates( + self, owner_client: AsyncClient, unowned_run + ): + """Unowned (legacy) runs cannot be updated by anyone.""" + response = await owner_client.patch( + f"{RUNS_BASE}/{unowned_run.id}", json={"name": "Stolen"} + ) + assert response.status_code == 403 + assert "no owner" in response.json()["detail"] + + +class TestRunDeleteOwnership: + """Tests for run DELETE ownership enforcement.""" + + async def test_owner_can_delete(self, owner_client: AsyncClient, owned_run): + """Owner can delete their own run.""" + response = await owner_client.delete(f"{RUNS_BASE}/{owned_run.id}") + assert response.status_code == 204 + + async def test_non_owner_cannot_delete(self, other_client: AsyncClient, owned_run): + """Non-owner gets 403 when trying to delete.""" + response = await other_client.delete(f"{RUNS_BASE}/{owned_run.id}") + assert response.status_code == 403 + + async def test_unauthenticated_cannot_delete(self, client: AsyncClient, owned_run): + """Unauthenticated user gets 401.""" + response = await client.delete(f"{RUNS_BASE}/{owned_run.id}") + assert response.status_code == 401 + + async def test_unowned_run_rejects_all_deletes( + self, owner_client: AsyncClient, unowned_run + ): + """Unowned (legacy) runs cannot be deleted by anyone.""" + response = await owner_client.delete(f"{RUNS_BASE}/{unowned_run.id}") + assert response.status_code == 403 + + +class TestEncounterCreateOwnership: + """Tests for encounter POST ownership enforcement.""" + + @pytest.fixture + async def pokemon(self, db_session: AsyncSession): + """Create a test Pokemon.""" + from app.models.pokemon import Pokemon + + pokemon = Pokemon( + pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"] + ) + db_session.add(pokemon) + await db_session.commit() + await db_session.refresh(pokemon) + return pokemon + + @pytest.fixture + async def route(self, db_session: AsyncSession, game: Game): + """Create a test route.""" + from app.models.route import Route + from app.models.version_group import VersionGroup + + vg = VersionGroup(name="Test VG", slug="test-vg") + db_session.add(vg) + await db_session.flush() + + game.version_group_id = vg.id + await db_session.flush() + + route = Route(name="Test Route", version_group_id=vg.id, order=1) + db_session.add(route) + await db_session.commit() + await db_session.refresh(route) + return route + + async def test_owner_can_create_encounter( + self, owner_client: AsyncClient, owned_run, pokemon, route + ): + """Owner can create encounters on their own run.""" + response = await owner_client.post( + f"{RUNS_BASE}/{owned_run.id}/encounters", + json={ + "routeId": route.id, + "pokemonId": pokemon.id, + "status": "caught", + }, + ) + assert response.status_code == 201 + + async def test_non_owner_cannot_create_encounter( + self, other_client: AsyncClient, owned_run, pokemon, route + ): + """Non-owner gets 403 when trying to create encounters.""" + response = await other_client.post( + f"{RUNS_BASE}/{owned_run.id}/encounters", + json={ + "routeId": route.id, + "pokemonId": pokemon.id, + "status": "caught", + }, + ) + assert response.status_code == 403 + + async def test_unauthenticated_cannot_create_encounter( + self, client: AsyncClient, owned_run, pokemon, route + ): + """Unauthenticated user gets 401.""" + response = await client.post( + f"{RUNS_BASE}/{owned_run.id}/encounters", + json={ + "routeId": route.id, + "pokemonId": pokemon.id, + "status": "caught", + }, + ) + assert response.status_code == 401 + + +class TestEncounterUpdateOwnership: + """Tests for encounter PATCH ownership enforcement.""" + + @pytest.fixture + async def encounter(self, db_session: AsyncSession, owned_run): + """Create a test encounter.""" + from app.models.encounter import Encounter + from app.models.pokemon import Pokemon + from app.models.route import Route + from app.models.version_group import VersionGroup + + pokemon = Pokemon( + pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"] + ) + db_session.add(pokemon) + await db_session.flush() + + game = await db_session.get(Game, owned_run.game_id) + if game.version_group_id is None: + vg = VersionGroup(name="Test VG", slug="test-vg") + db_session.add(vg) + await db_session.flush() + game.version_group_id = vg.id + await db_session.flush() + else: + vg = await db_session.get(VersionGroup, game.version_group_id) + + route = Route(name="Test Route", version_group_id=vg.id, order=1) + db_session.add(route) + await db_session.flush() + + encounter = Encounter( + run_id=owned_run.id, + route_id=route.id, + pokemon_id=pokemon.id, + status="caught", + ) + db_session.add(encounter) + await db_session.commit() + await db_session.refresh(encounter) + return encounter + + async def test_owner_can_update_encounter( + self, owner_client: AsyncClient, encounter + ): + """Owner can update encounters on their own run.""" + response = await owner_client.patch( + f"{ENC_BASE}/{encounter.id}", json={"nickname": "Sparky"} + ) + assert response.status_code == 200 + assert response.json()["nickname"] == "Sparky" + + async def test_non_owner_cannot_update_encounter( + self, other_client: AsyncClient, encounter + ): + """Non-owner gets 403 when trying to update encounters.""" + response = await other_client.patch( + f"{ENC_BASE}/{encounter.id}", json={"nickname": "Stolen"} + ) + assert response.status_code == 403 + + async def test_unauthenticated_cannot_update_encounter( + self, client: AsyncClient, encounter + ): + """Unauthenticated user gets 401.""" + response = await client.patch( + f"{ENC_BASE}/{encounter.id}", json={"nickname": "Stolen"} + ) + assert response.status_code == 401 + + +class TestEncounterDeleteOwnership: + """Tests for encounter DELETE ownership enforcement.""" + + @pytest.fixture + async def encounter(self, db_session: AsyncSession, owned_run): + """Create a test encounter.""" + from app.models.encounter import Encounter + from app.models.pokemon import Pokemon + from app.models.route import Route + from app.models.version_group import VersionGroup + + pokemon = Pokemon( + pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"] + ) + db_session.add(pokemon) + await db_session.flush() + + game = await db_session.get(Game, owned_run.game_id) + if game.version_group_id is None: + vg = VersionGroup(name="Test VG", slug="test-vg") + db_session.add(vg) + await db_session.flush() + game.version_group_id = vg.id + await db_session.flush() + else: + vg = await db_session.get(VersionGroup, game.version_group_id) + + route = Route(name="Test Route", version_group_id=vg.id, order=1) + db_session.add(route) + await db_session.flush() + + encounter = Encounter( + run_id=owned_run.id, + route_id=route.id, + pokemon_id=pokemon.id, + status="caught", + ) + db_session.add(encounter) + await db_session.commit() + await db_session.refresh(encounter) + return encounter + + async def test_owner_can_delete_encounter( + self, owner_client: AsyncClient, encounter + ): + """Owner can delete encounters on their own run.""" + response = await owner_client.delete(f"{ENC_BASE}/{encounter.id}") + assert response.status_code == 204 + + async def test_non_owner_cannot_delete_encounter( + self, other_client: AsyncClient, encounter + ): + """Non-owner gets 403 when trying to delete encounters.""" + response = await other_client.delete(f"{ENC_BASE}/{encounter.id}") + assert response.status_code == 403 + + async def test_unauthenticated_cannot_delete_encounter( + self, client: AsyncClient, encounter + ): + """Unauthenticated user gets 401.""" + response = await client.delete(f"{ENC_BASE}/{encounter.id}") + assert response.status_code == 401 + + +class TestRunVisibilityPreserved: + """Verify read access still works for public runs.""" + + async def test_non_owner_can_read_public_run( + self, other_client: AsyncClient, owned_run + ): + """Non-owner can read (but not modify) a public run.""" + response = await other_client.get(f"{RUNS_BASE}/{owned_run.id}") + assert response.status_code == 200 + assert response.json()["id"] == owned_run.id + + async def test_unauthenticated_can_read_public_run( + self, client: AsyncClient, owned_run + ): + """Unauthenticated user can read a public run.""" + response = await client.get(f"{RUNS_BASE}/{owned_run.id}") + assert response.status_code == 200 diff --git a/backend/tests/test_runs.py b/backend/tests/test_runs.py index ed48f01..651234b 100644 --- a/backend/tests/test_runs.py +++ b/backend/tests/test_runs.py @@ -1,5 +1,7 @@ """Integration tests for the Runs & Encounters API.""" +from uuid import UUID + import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession @@ -8,8 +10,11 @@ from app.models.game import Game from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route +from app.models.user import User from app.models.version_group import VersionGroup +MOCK_AUTH_USER_ID = UUID("00000000-0000-4000-a000-000000000001") + RUNS_BASE = "/api/v1/runs" ENC_BASE = "/api/v1/encounters" @@ -42,6 +47,11 @@ async def run(auth_client: AsyncClient, game_id: int) -> dict: @pytest.fixture async def enc_ctx(db_session: AsyncSession) -> dict: """Full context for encounter tests: game, run, pokemon, standalone and grouped routes.""" + # Create the mock auth user to own the run + user = User(id=MOCK_AUTH_USER_ID, email="test@example.com") + db_session.add(user) + await db_session.flush() + vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg") db_session.add(vg) await db_session.flush() @@ -83,6 +93,7 @@ async def enc_ctx(db_session: AsyncSession) -> dict: run = NuzlockeRun( game_id=game.id, + owner_id=user.id, name="Enc Run", status="active", rules={"shinyClause": True, "giftClause": False}, diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index b8bd8c5..784e90e 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -67,7 +67,7 @@ export function RunDashboard() { const [teamSort, setTeamSort] = useState('route') const isOwner = user && run?.owner?.id === user.id - const canEdit = isOwner || !run?.owner + const canEdit = isOwner const encounters = run?.encounters ?? [] const alive = useMemo( @@ -143,6 +143,32 @@ export function RunDashboard() { + {/* Read-only Banner */} + {!canEdit && run.owner && ( +
+
+ + + + + + Viewing {run.owner.displayName ? `${run.owner.displayName}'s` : "another player's"}{' '} + run (read-only) + +
+
+ )} + {/* Completion Banner */} {!isActive && (
isExpanded: boolean onToggleExpand: () => void - onRouteClick: (route: Route) => void + onRouteClick: ((route: Route) => void) | undefined filter: 'all' | RouteStatus pinwheelClause: boolean } @@ -438,10 +439,12 @@ function RouteGroup({
- {isActive && run.rules?.shinyClause && ( + {isActive && canEdit && run.rules?.shinyClause && ( )} - {isActive && ( + {isActive && canEdit && ( )} - {isActive && ( + {isActive && canEdit && (
+ {/* Read-only Banner */} + {!canEdit && run.owner && ( +
+
+ + + + + + Viewing {run.owner.displayName ? `${run.owner.displayName}'s` : "another player's"}{' '} + run (read-only) + +
+
+ )} + {/* Completion Banner */} {!isActive && (
- {run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && ( + {run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && canEdit && ( + {canEdit && ( + + )} {hofTeam ? (
@@ -1262,7 +1297,9 @@ export function RunEncounters() { setSelectedTeamEncounter(enc) : undefined} + onClick={ + isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined + } /> ))}
@@ -1276,7 +1313,9 @@ export function RunEncounters() { key={enc.id} encounter={enc} showFaintLevel - onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} + onClick={ + isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined + } /> ))} @@ -1292,7 +1331,9 @@ export function RunEncounters() {
setSelectedTeamEncounter(enc) : undefined} + onEncounterClick={ + isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined + } />
)} @@ -1306,7 +1347,7 @@ export function RunEncounters() { setSelectedTeamEncounter(enc) : undefined} + onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined} /> ))} @@ -1318,7 +1359,7 @@ export function RunEncounters() {

Encounters

- {isActive && completedCount < totalLocations && ( + {isActive && canEdit && completedCount < totalLocations && (