Merge pull request 'Enforce run ownership and show owner info' (#74) from feature/enforce-run-ownership-on-all-mutation-endpoints into develop
All checks were successful
CI / backend-tests (push) Successful in 29s
CI / frontend-tests (push) Successful in 29s

Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
2026-03-22 09:16:54 +01:00
18 changed files with 1384 additions and 140 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -5,7 +5,7 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.core.database import get_session
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
@@ -344,12 +344,14 @@ async def create_boss_result(
run_id: int, run_id: int,
data: BossResultCreate, data: BossResultCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), user: AuthUser = Depends(require_auth),
): ):
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
boss = await session.get(BossBattle, data.boss_battle_id) boss = await session.get(BossBattle, data.boss_battle_id)
if boss is None: if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found") raise HTTPException(status_code=404, detail="Boss battle not found")
@@ -425,8 +427,14 @@ async def delete_boss_result(
run_id: int, run_id: int,
result_id: int, result_id: int,
session: AsyncSession = Depends(get_session), 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( result = await session.execute(
select(BossResult).where( select(BossResult).where(
BossResult.id == result_id, BossResult.run_id == run_id BossResult.id == result_id, BossResult.run_id == run_id

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload 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.core.database import get_session
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
@@ -36,13 +36,15 @@ async def create_encounter(
run_id: int, run_id: int,
data: EncounterCreate, data: EncounterCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), user: AuthUser = Depends(require_auth),
): ):
# Validate run exists # Validate run exists
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
# Validate route exists and load its children # Validate route exists and load its children
result = await session.execute( result = await session.execute(
select(Route) select(Route)
@@ -139,12 +141,17 @@ async def update_encounter(
encounter_id: int, encounter_id: int,
data: EncounterUpdate, data: EncounterUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), user: AuthUser = Depends(require_auth),
): ):
encounter = await session.get(Encounter, encounter_id) encounter = await session.get(Encounter, encounter_id)
if encounter is None: if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found") 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) update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(encounter, field, value) setattr(encounter, field, value)
@@ -168,12 +175,17 @@ async def update_encounter(
async def delete_encounter( async def delete_encounter(
encounter_id: int, encounter_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), user: AuthUser = Depends(require_auth),
): ):
encounter = await session.get(Encounter, encounter_id) encounter = await session.get(Encounter, encounter_id)
if encounter is None: if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found") 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 # Block deletion if encounter is referenced by a genlocke transfer
transfer_result = await session.execute( transfer_result = await session.execute(
select(GenlockeTransfer.id).where( select(GenlockeTransfer.id).where(
@@ -200,12 +212,15 @@ async def delete_encounter(
async def bulk_randomize_encounters( async def bulk_randomize_encounters(
run_id: int, run_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), user: AuthUser = Depends(require_auth),
): ):
# 1. Validate run # 1. Validate run
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
if run.status != "active": if run.status != "active":
raise HTTPException(status_code=400, detail="Run is not active") raise HTTPException(status_code=400, detail="Run is not active")

View File

@@ -1,3 +1,5 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import delete as sa_delete 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.core.database import get_session
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.genlocke_transfer import GenlockeTransfer 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.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.user import User
from app.schemas.genlocke import ( from app.schemas.genlocke import (
AddLegRequest, AddLegRequest,
AdvanceLegRequest, AdvanceLegRequest,
@@ -25,6 +28,7 @@ from app.schemas.genlocke import (
GenlockeLegDetailResponse, GenlockeLegDetailResponse,
GenlockeLineageResponse, GenlockeLineageResponse,
GenlockeListItem, GenlockeListItem,
GenlockeOwnerResponse,
GenlockeResponse, GenlockeResponse,
GenlockeStatsResponse, GenlockeStatsResponse,
GenlockeUpdate, GenlockeUpdate,
@@ -41,12 +45,72 @@ from app.services.families import build_families, resolve_base_form
router = APIRouter() 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]) @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( result = await session.execute(
select(Genlocke) select(Genlocke)
.options( .options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), selectinload(Genlocke.legs)
.selectinload(GenlockeLeg.run)
.selectinload(NuzlockeRun.owner),
) )
.order_by(Genlocke.created_at.desc()) .order_by(Genlocke.created_at.desc())
) )
@@ -54,8 +118,22 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
items = [] items = []
for g in genlockes: for g in genlockes:
# Filter out private genlockes for non-owners
if not _is_genlocke_visible(g, user):
continue
completed_legs = 0 completed_legs = 0
current_leg_order = None 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: for leg in g.legs:
if leg.run and leg.run.status == "completed": if leg.run and leg.run.status == "completed":
completed_legs += 1 completed_legs += 1
@@ -71,13 +149,18 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
total_legs=len(g.legs), total_legs=len(g.legs),
completed_legs=completed_legs, completed_legs=completed_legs,
current_leg_order=current_leg_order, current_leg_order=current_leg_order,
owner=owner,
) )
) )
return items return items
@router.get("/{genlocke_id}", response_model=GenlockeDetailResponse) @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( result = await session.execute(
select(Genlocke) select(Genlocke)
.where(Genlocke.id == genlocke_id) .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: if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found") 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 # Collect run IDs for aggregate query
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None] 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, response_model=GenlockeGraveyardResponse,
) )
async def get_genlocke_graveyard( 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( result = await session.execute(
select(Genlocke) select(Genlocke)
.where(Genlocke.id == genlocke_id) .where(Genlocke.id == genlocke_id)
.options( .options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
) )
) )
genlocke = result.scalar_one_or_none() genlocke = result.scalar_one_or_none()
if genlocke is None: if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found") 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 # 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_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
run_lookup: dict[int, tuple[int, str]] = {} run_lookup: dict[int, tuple[int, str]] = {}
@@ -274,20 +367,26 @@ async def get_genlocke_graveyard(
response_model=GenlockeLineageResponse, response_model=GenlockeLineageResponse,
) )
async def get_genlocke_lineages( 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( result = await session.execute(
select(Genlocke) select(Genlocke)
.where(Genlocke.id == genlocke_id) .where(Genlocke.id == genlocke_id)
.options( .options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
) )
) )
genlocke = result.scalar_one_or_none() genlocke = result.scalar_one_or_none()
if genlocke is None: if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found") 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 # Query all transfers for this genlocke
transfer_result = await session.execute( transfer_result = await session.execute(
select(GenlockeTransfer).where(GenlockeTransfer.genlocke_id == genlocke_id) select(GenlockeTransfer).where(GenlockeTransfer.genlocke_id == genlocke_id)
@@ -440,7 +539,7 @@ async def get_genlocke_lineages(
async def create_genlocke( async def create_genlocke(
data: GenlockeCreate, data: GenlockeCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth), user: AuthUser = Depends(require_auth),
): ):
if not data.game_ids: if not data.game_ids:
raise HTTPException(status_code=400, detail="At least one game is required") raise HTTPException(status_code=400, detail="At least one game is required")
@@ -455,6 +554,13 @@ async def create_genlocke(
if missing: if missing:
raise HTTPException(status_code=404, detail=f"Games not found: {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 # Create genlocke
genlocke = Genlocke( genlocke = Genlocke(
name=data.name.strip(), name=data.name.strip(),
@@ -481,6 +587,7 @@ async def create_genlocke(
first_game = found_games[data.game_ids[0]] first_game = found_games[data.game_ids[0]]
first_run = NuzlockeRun( first_run = NuzlockeRun(
game_id=first_game.id, game_id=first_game.id,
owner_id=user_id,
name=f"{data.name.strip()} \u2014 Leg 1", name=f"{data.name.strip()} \u2014 Leg 1",
status="active", status="active",
rules=data.nuzlocke_rules, rules=data.nuzlocke_rules,
@@ -513,15 +620,23 @@ async def get_leg_survivors(
genlocke_id: int, genlocke_id: int,
leg_order: int, leg_order: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
): ):
# Find the leg # Load genlocke with legs + run for visibility check
result = await session.execute( genlocke_result = await session.execute(
select(GenlockeLeg).where( select(Genlocke)
GenlockeLeg.genlocke_id == genlocke_id, .where(Genlocke.id == genlocke_id)
GenlockeLeg.leg_order == leg_order, .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: if leg is None:
raise HTTPException(status_code=404, detail="Leg not found") raise HTTPException(status_code=404, detail="Leg not found")
@@ -571,8 +686,10 @@ async def advance_leg(
leg_order: int, leg_order: int,
data: AdvanceLegRequest | None = None, data: AdvanceLegRequest | None = None,
session: AsyncSession = Depends(get_session), 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 # Load genlocke with legs
result = await session.execute( result = await session.execute(
select(Genlocke) select(Genlocke)
@@ -653,9 +770,10 @@ async def advance_leg(
else: else:
current_leg.retired_pokemon_ids = [] 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( new_run = NuzlockeRun(
game_id=next_leg.game_id, game_id=next_leg.game_id,
owner_id=current_run.owner_id,
name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}", name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}",
status="active", status="active",
rules=genlocke.nuzlocke_rules, rules=genlocke.nuzlocke_rules,
@@ -786,12 +904,21 @@ class RetiredFamiliesResponse(BaseModel):
async def get_retired_families( async def get_retired_families(
genlocke_id: int, genlocke_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
): ):
# Verify genlocke exists # Load genlocke with legs + run for visibility check
genlocke = await session.get(Genlocke, genlocke_id) 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: if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found") 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 # Query all legs with retired_pokemon_ids
result = await session.execute( result = await session.execute(
select(GenlockeLeg) select(GenlockeLeg)
@@ -826,8 +953,10 @@ async def update_genlocke(
genlocke_id: int, genlocke_id: int,
data: GenlockeUpdate, data: GenlockeUpdate,
session: AsyncSession = Depends(get_session), 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( result = await session.execute(
select(Genlocke) select(Genlocke)
.where(Genlocke.id == genlocke_id) .where(Genlocke.id == genlocke_id)
@@ -863,8 +992,10 @@ async def update_genlocke(
async def delete_genlocke( async def delete_genlocke(
genlocke_id: int, genlocke_id: int,
session: AsyncSession = Depends(get_session), 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) genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None: if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found") raise HTTPException(status_code=404, detail="Genlocke not found")
@@ -895,8 +1026,10 @@ async def add_leg(
genlocke_id: int, genlocke_id: int,
data: AddLegRequest, data: AddLegRequest,
session: AsyncSession = Depends(get_session), 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) genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None: if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found") raise HTTPException(status_code=404, detail="Genlocke not found")
@@ -938,8 +1071,10 @@ async def remove_leg(
genlocke_id: int, genlocke_id: int,
leg_id: int, leg_id: int,
session: AsyncSession = Depends(get_session), 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( result = await session.execute(
select(GenlockeLeg).where( select(GenlockeLeg).where(
GenlockeLeg.id == leg_id, GenlockeLeg.id == leg_id,

View File

@@ -6,7 +6,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload 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.core.database import get_session
from app.models.boss_result import BossResult from app.models.boss_result import BossResult
from app.models.encounter import Encounter from app.models.encounter import Encounter
@@ -181,31 +181,17 @@ def _build_run_response(run: NuzlockeRun) -> RunResponse:
) )
def _check_run_access( def _check_run_read_access(run: NuzlockeRun, user: AuthUser | None) -> None:
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
) -> 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. 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: 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 return
user_id = UUID(user.id) if user else None 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: if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
raise HTTPException(status_code=403, detail="This run is private") 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") raise HTTPException(status_code=404, detail="Run not found")
# Check visibility access # Check visibility access
_check_run_access(run, user) _check_run_read_access(run, user)
# Check if this run belongs to a genlocke # Check if this run belongs to a genlocke
genlocke_context = None genlocke_context = None
@@ -375,8 +361,7 @@ async def update_run(
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
# Check ownership for mutations (unowned runs allow anyone for backwards compat) require_run_owner(run, user)
_check_run_access(run, user, require_owner=run.owner_id is not None)
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
@@ -484,8 +469,7 @@ async def delete_run(
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
# Check ownership for deletion (unowned runs allow anyone for backwards compat) require_run_owner(run, user)
_check_run_access(run, user, require_owner=run.owner_id is not None)
# Block deletion if run is linked to a genlocke leg # Block deletion if run is linked to a genlocke leg
leg_result = await session.execute( leg_result = await session.execute(

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_session from app.core.database import get_session
from app.models.nuzlocke_run import NuzlockeRun
from app.models.user import User from app.models.user import User
@@ -105,3 +106,20 @@ async def require_admin(
detail="Admin access required", detail="Admin access required",
) )
return user 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",
)

View File

@@ -1,10 +1,16 @@
from datetime import datetime from datetime import datetime
from uuid import UUID
from app.schemas.base import CamelModel from app.schemas.base import CamelModel
from app.schemas.game import GameResponse from app.schemas.game import GameResponse
from app.schemas.pokemon import PokemonResponse from app.schemas.pokemon import PokemonResponse
class GenlockeOwnerResponse(CamelModel):
id: UUID
display_name: str | None = None
class GenlockeCreate(CamelModel): class GenlockeCreate(CamelModel):
name: str name: str
game_ids: list[int] game_ids: list[int]
@@ -92,6 +98,7 @@ class GenlockeListItem(CamelModel):
total_legs: int total_legs: int
completed_legs: int completed_legs: int
current_leg_order: int | None = None current_leg_order: int | None = None
owner: GenlockeOwnerResponse | None = None
class GenlockeDetailResponse(CamelModel): class GenlockeDetailResponse(CamelModel):

View File

@@ -1,10 +1,13 @@
"""Integration tests for the Genlockes & Bosses API.""" """Integration tests for the Genlockes & Bosses API."""
import pytest import pytest
from httpx import AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession 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.game import Game
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.version_group import VersionGroup from app.models.version_group import VersionGroup
@@ -55,7 +58,9 @@ async def games_ctx(db_session: AsyncSession) -> dict:
@pytest.fixture @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.""" """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) 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) 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 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 # Genlockes — create
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -259,14 +436,18 @@ class TestGenlockeLegs:
class TestAdvanceLeg: 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.""" """Cannot advance when leg 1's run is still active."""
response = await admin_client.post( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
) )
assert response.status_code == 400 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.""" """A single-leg genlocke cannot be advanced."""
r = await admin_client.post( r = await admin_client.post(
GENLOCKES_BASE, GENLOCKES_BASE,
@@ -283,7 +464,9 @@ class TestAdvanceLeg:
async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict): async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict):
"""Completing the current run allows advancing to the next leg.""" """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( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" 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): async def test_advances_with_transfers(self, admin_client: AsyncClient, ctx: dict):
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg.""" """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( response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance", f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
@@ -319,30 +504,40 @@ class TestAdvanceLeg:
class TestGenlockeGraveyard: class TestGenlockeGraveyard:
async def test_returns_empty_graveyard(self, admin_client: AsyncClient, ctx: dict): 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 assert response.status_code == 200
data = response.json() data = response.json()
assert data["entries"] == [] assert data["entries"] == []
assert data["totalDeaths"] == 0 assert data["totalDeaths"] == 0
async def test_not_found_returns_404(self, admin_client: AsyncClient): 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: class TestGenlockeLineages:
async def test_returns_empty_lineages(self, admin_client: AsyncClient, ctx: dict): 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 assert response.status_code == 200
data = response.json() data = response.json()
assert data["lineages"] == [] assert data["lineages"] == []
assert data["totalLineages"] == 0 assert data["totalLineages"] == 0
async def test_not_found_returns_404(self, admin_client: AsyncClient): 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: 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( response = await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families" f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
) )
@@ -365,9 +560,13 @@ class TestLegSurvivors:
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()) == 1 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 ( 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 ).status_code == 404
@@ -386,7 +585,9 @@ BOSS_PAYLOAD = {
class TestBossCRUD: class TestBossCRUD:
async def test_empty_list(self, admin_client: AsyncClient, games_ctx: dict): 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.status_code == 200
assert response.json() == [] assert response.json() == []
@@ -441,7 +642,9 @@ class TestBossCRUD:
async def test_invalid_game_returns_404(self, admin_client: AsyncClient): async def test_invalid_game_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404 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 = ( game = (
await admin_client.post( await admin_client.post(
GAMES_BASE, GAMES_BASE,
@@ -480,7 +683,9 @@ class TestBossResults:
return {"boss_id": boss["id"], "run_id": run["id"]} return {"boss_id": boss["id"], "run_id": run["id"]}
async def test_empty_list(self, admin_client: AsyncClient, boss_ctx: dict): 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.status_code == 200
assert response.json() == [] assert response.json() == []
@@ -495,7 +700,9 @@ class TestBossResults:
assert data["attempts"] == 1 assert data["attempts"] == 1
assert data["completedAt"] is not None 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).""" """POSTing the same boss twice updates the result (upsert)."""
await admin_client.post( await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", 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") await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json() == [] ).json() == []
async def test_invalid_run_returns_404(self, admin_client: AsyncClient, boss_ctx: dict): async def test_invalid_run_returns_404(
assert (await admin_client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 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( response = await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": 9999, "result": "won"}, json={"bossBattleId": 9999, "result": "won"},
@@ -587,8 +800,16 @@ class TestExport:
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json(), list) assert isinstance(response.json(), list)
async def test_export_game_routes_not_found_returns_404(self, admin_client: AsyncClient): async def test_export_game_routes_not_found_returns_404(
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 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): async def test_export_game_bosses_not_found_returns_404(
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404 self, admin_client: AsyncClient
):
assert (
await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")
).status_code == 404

View File

@@ -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

View File

@@ -1,5 +1,7 @@
"""Integration tests for the Runs & Encounters API.""" """Integration tests for the Runs & Encounters API."""
from uuid import UUID
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession 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.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.user import User
from app.models.version_group import VersionGroup from app.models.version_group import VersionGroup
MOCK_AUTH_USER_ID = UUID("00000000-0000-4000-a000-000000000001")
RUNS_BASE = "/api/v1/runs" RUNS_BASE = "/api/v1/runs"
ENC_BASE = "/api/v1/encounters" ENC_BASE = "/api/v1/encounters"
@@ -42,6 +47,11 @@ async def run(auth_client: AsyncClient, game_id: int) -> dict:
@pytest.fixture @pytest.fixture
async def enc_ctx(db_session: AsyncSession) -> dict: async def enc_ctx(db_session: AsyncSession) -> dict:
"""Full context for encounter tests: game, run, pokemon, standalone and grouped routes.""" """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") vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg")
db_session.add(vg) db_session.add(vg)
await db_session.flush() await db_session.flush()
@@ -83,6 +93,7 @@ async def enc_ctx(db_session: AsyncSession) -> dict:
run = NuzlockeRun( run = NuzlockeRun(
game_id=game.id, game_id=game.id,
owner_id=user.id,
name="Enc Run", name="Enc Run",
status="active", status="active",
rules={"shinyClause": True, "giftClause": False}, rules={"shinyClause": True, "giftClause": False},

View File

@@ -67,7 +67,7 @@ export function RunDashboard() {
const [teamSort, setTeamSort] = useState<TeamSortKey>('route') const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const isOwner = user && run?.owner?.id === user.id const isOwner = user && run?.owner?.id === user.id
const canEdit = isOwner || !run?.owner const canEdit = isOwner
const encounters = run?.encounters ?? [] const encounters = run?.encounters ?? []
const alive = useMemo( const alive = useMemo(
@@ -143,6 +143,32 @@ export function RunDashboard() {
</div> </div>
</div> </div>
{/* Read-only Banner */}
{!canEdit && run.owner && (
<div className="rounded-lg p-3 mb-6 bg-surface-2 border border-border-default">
<div className="flex items-center gap-2 text-text-secondary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span className="text-sm">
Viewing {run.owner.displayName ? `${run.owner.displayName}'s` : "another player's"}{' '}
run (read-only)
</span>
</div>
</div>
)}
{/* Completion Banner */} {/* Completion Banner */}
{!isActive && ( {!isActive && (
<div <div

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react' import { useState, useMemo, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom' import { useParams, Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRun, useUpdateRun } from '../hooks/useRuns' import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes' import { useAdvanceLeg } from '../hooks/useGenlockes'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
@@ -286,7 +287,7 @@ interface RouteGroupProps {
giftEncounterByRoute: Map<number, EncounterDetail> giftEncounterByRoute: Map<number, EncounterDetail>
isExpanded: boolean isExpanded: boolean
onToggleExpand: () => void onToggleExpand: () => void
onRouteClick: (route: Route) => void onRouteClick: ((route: Route) => void) | undefined
filter: 'all' | RouteStatus filter: 'all' | RouteStatus
pinwheelClause: boolean pinwheelClause: boolean
} }
@@ -438,10 +439,12 @@ function RouteGroup({
<button <button
key={child.id} key={child.id}
type="button" type="button"
onClick={() => !isDisabled && onRouteClick(child)} onClick={() => !isDisabled && onRouteClick?.(child)}
disabled={isDisabled} disabled={isDisabled || !onRouteClick}
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${ className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${
isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-surface-2/50' isDisabled || !onRouteClick
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-surface-2/50'
} ${childSi.bg}`} } ${childSi.bg}`}
> >
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} /> <span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
@@ -501,6 +504,10 @@ export function RunEncounters() {
const { data: bosses } = useGameBosses(run?.gameId ?? null) const { data: bosses } = useGameBosses(run?.gameId ?? null)
const { data: bossResults } = useBossResults(runIdNum) const { data: bossResults } = useBossResults(runIdNum)
const createBossResult = useCreateBossResult(runIdNum) const createBossResult = useCreateBossResult(runIdNum)
const { user } = useAuth()
const isOwner = user && run?.owner?.id === user.id
const canEdit = isOwner
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null) const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null) const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
@@ -944,7 +951,7 @@ export function RunEncounters() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isActive && run.rules?.shinyClause && ( {isActive && canEdit && run.rules?.shinyClause && (
<button <button
onClick={() => setShowShinyModal(true)} onClick={() => setShowShinyModal(true)}
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors" className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
@@ -952,7 +959,7 @@ export function RunEncounters() {
&#10022; Log Shiny &#10022; Log Shiny
</button> </button>
)} )}
{isActive && ( {isActive && canEdit && (
<button <button
onClick={() => setShowEggModal(true)} onClick={() => setShowEggModal(true)}
className="px-3 py-1 text-sm border border-green-600 text-status-active rounded-full font-medium hover:bg-green-900/20 transition-colors" className="px-3 py-1 text-sm border border-green-600 text-status-active rounded-full font-medium hover:bg-green-900/20 transition-colors"
@@ -960,7 +967,7 @@ export function RunEncounters() {
&#x1F95A; Log Egg &#x1F95A; Log Egg
</button> </button>
)} )}
{isActive && ( {isActive && canEdit && (
<button <button
onClick={() => setShowEndRun(true)} onClick={() => setShowEndRun(true)}
className="px-3 py-1 text-sm border border-border-default rounded-full font-medium hover:bg-surface-2 transition-colors" className="px-3 py-1 text-sm border border-border-default rounded-full font-medium hover:bg-surface-2 transition-colors"
@@ -977,6 +984,32 @@ export function RunEncounters() {
</div> </div>
</div> </div>
{/* Read-only Banner */}
{!canEdit && run.owner && (
<div className="rounded-lg p-3 mb-6 bg-surface-2 border border-border-default">
<div className="flex items-center gap-2 text-text-secondary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span className="text-sm">
Viewing {run.owner.displayName ? `${run.owner.displayName}'s` : "another player's"}{' '}
run (read-only)
</span>
</div>
</div>
)}
{/* Completion Banner */} {/* Completion Banner */}
{!isActive && ( {!isActive && (
<div <div
@@ -1025,7 +1058,7 @@ export function RunEncounters() {
</p> </p>
</div> </div>
</div> </div>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && ( {run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && canEdit && (
<button <button
onClick={() => { onClick={() => {
if (hofTeam && hofTeam.length > 0) { if (hofTeam && hofTeam.length > 0) {
@@ -1063,13 +1096,15 @@ export function RunEncounters() {
<span className="text-xs font-medium text-text-link uppercase tracking-wider"> <span className="text-xs font-medium text-text-link uppercase tracking-wider">
Hall of Fame Hall of Fame
</span> </span>
<button {canEdit && (
type="button" <button
onClick={() => setShowHofModal(true)} type="button"
className="text-xs text-blue-400 hover:text-accent-300" onClick={() => setShowHofModal(true)}
> className="text-xs text-blue-400 hover:text-accent-300"
{hofTeam ? 'Edit' : 'Select team'} >
</button> {hofTeam ? 'Edit' : 'Select team'}
</button>
)}
</div> </div>
{hofTeam ? ( {hofTeam ? (
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -1262,7 +1297,9 @@ export function RunEncounters() {
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/> />
))} ))}
</div> </div>
@@ -1276,7 +1313,9 @@ export function RunEncounters() {
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/> />
))} ))}
</div> </div>
@@ -1292,7 +1331,9 @@ export function RunEncounters() {
<div className="mb-6"> <div className="mb-6">
<ShinyBox <ShinyBox
encounters={shinyEncounters} encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined} onEncounterClick={
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
/> />
</div> </div>
)} )}
@@ -1306,7 +1347,7 @@ export function RunEncounters() {
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
/> />
))} ))}
</div> </div>
@@ -1318,7 +1359,7 @@ export function RunEncounters() {
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2> <h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && ( {isActive && canEdit && completedCount < totalLocations && (
<button <button
type="button" type="button"
disabled={bulkRandomize.isPending} disabled={bulkRandomize.isPending}
@@ -1409,7 +1450,7 @@ export function RunEncounters() {
giftEncounterByRoute={giftEncounterByRoute} giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)} isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)} onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick} onRouteClick={canEdit ? handleRouteClick : undefined}
filter={filter} filter={filter}
pinwheelClause={pinwheelClause} pinwheelClause={pinwheelClause}
/> />
@@ -1425,8 +1466,9 @@ export function RunEncounters() {
<button <button
key={route.id} key={route.id}
type="button" type="button"
onClick={() => handleRouteClick(route)} onClick={canEdit ? () => handleRouteClick(route) : undefined}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`} disabled={!canEdit}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`}
> >
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} /> <span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -1578,7 +1620,7 @@ export function RunEncounters() {
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800"> <span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003; Defeated &#10003;
</span> </span>
) : isActive ? ( ) : isActive && canEdit ? (
<button <button
onClick={() => setSelectedBoss(boss)} onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors" className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
@@ -1593,35 +1635,44 @@ export function RunEncounters() {
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} /> <BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)} )}
{/* Player team snapshot */} {/* Player team snapshot */}
{isDefeated && (() => { {isDefeated &&
const result = bossResultByBattleId.get(boss.id) (() => {
if (!result || result.team.length === 0) return null const result = bossResultByBattleId.get(boss.id)
return ( if (!result || result.team.length === 0) return null
<div className="mt-3 pt-3 border-t border-border-default"> return (
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p> <div className="mt-3 pt-3 border-t border-border-default">
<div className="flex gap-2 flex-wrap"> <p className="text-xs font-medium text-text-secondary mb-2">
{result.team.map((tm: BossResultTeamMember) => { Your Team
const enc = encounterById.get(tm.encounterId) </p>
if (!enc) return null <div className="flex gap-2 flex-wrap">
const dp = enc.currentPokemon ?? enc.pokemon {result.team.map((tm: BossResultTeamMember) => {
return ( const enc = encounterById.get(tm.encounterId)
<div key={tm.id} className="flex flex-col items-center"> if (!enc) return null
{dp.spriteUrl ? ( const dp = enc.currentPokemon ?? enc.pokemon
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" /> return (
) : ( <div key={tm.id} className="flex flex-col items-center">
<div className="w-10 h-10 bg-surface-3 rounded-full" /> {dp.spriteUrl ? (
)} <img
<span className="text-[10px] text-text-tertiary capitalize"> src={dp.spriteUrl}
{enc.nickname ?? dp.name} alt={dp.name}
</span> className="w-10 h-10"
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span> />
</div> ) : (
) <div className="w-10 h-10 bg-surface-3 rounded-full" />
})} )}
<span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name}
</span>
<span className="text-[10px] text-text-muted">
Lv.{tm.level}
</span>
</div>
)
})}
</div>
</div> </div>
</div> )
) })()}
})()}
</div> </div>
{sectionAfter && ( {sectionAfter && (
<div className="flex items-center gap-3 my-4"> <div className="flex items-center gap-3 my-4">

View File

@@ -13,14 +13,46 @@ export function AdminGenlockes() {
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null) const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!statusFilter) return genlockes let result = genlockes
return genlockes.filter((g) => g.status === statusFilter) if (statusFilter) result = result.filter((g) => g.status === statusFilter)
}, [genlockes, statusFilter]) if (ownerFilter) {
if (ownerFilter === '__none__') {
result = result.filter((g) => !g.owner)
} else {
result = result.filter((g) => g.owner?.id === ownerFilter)
}
}
return result
}, [genlockes, statusFilter, ownerFilter])
const genlockeOwners = useMemo(() => {
const owners = new Map<string, string>()
let hasUnowned = false
for (const g of genlockes) {
if (g.owner) {
owners.set(g.owner.id, g.owner.displayName ?? g.owner.id)
} else {
hasUnowned = true
}
}
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
return { owners: sorted, hasUnowned }
}, [genlockes])
const columns: Column<GenlockeListItem>[] = [ const columns: Column<GenlockeListItem>[] = [
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name }, { header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
{
header: 'Owner',
accessor: (g) => (
<span className={g.owner ? '' : 'text-text-tertiary'}>
{g.owner?.displayName ?? g.owner?.id ?? 'No owner'}
</span>
),
sortKey: (g) => g.owner?.displayName ?? g.owner?.id ?? '',
},
{ {
header: 'Status', header: 'Status',
accessor: (g) => ( accessor: (g) => (
@@ -67,9 +99,25 @@ export function AdminGenlockes() {
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
</select> </select>
{statusFilter && ( <select
value={ownerFilter}
onChange={(e) => setOwnerFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
>
<option value="">All owners</option>
{genlockeOwners.hasUnowned && <option value="__none__">No owner</option>}
{genlockeOwners.owners.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || ownerFilter) && (
<button <button
onClick={() => setStatusFilter('')} onClick={() => {
setStatusFilter('')
setOwnerFilter('')
}}
className="text-sm text-text-tertiary hover:text-text-primary" className="text-sm text-text-tertiary hover:text-text-primary"
> >
Clear filters Clear filters

View File

@@ -13,6 +13,7 @@ export function AdminRuns() {
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null) const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [gameFilter, setGameFilter] = useState('') const [gameFilter, setGameFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games]) const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
@@ -20,8 +21,15 @@ export function AdminRuns() {
let result = runs let result = runs
if (statusFilter) result = result.filter((r) => r.status === statusFilter) if (statusFilter) result = result.filter((r) => r.status === statusFilter)
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter)) if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
if (ownerFilter) {
if (ownerFilter === '__none__') {
result = result.filter((r) => !r.owner)
} else {
result = result.filter((r) => r.owner?.id === ownerFilter)
}
}
return result return result
}, [runs, statusFilter, gameFilter]) }, [runs, statusFilter, gameFilter, ownerFilter])
const runGames = useMemo( const runGames = useMemo(
() => () =>
@@ -33,6 +41,20 @@ export function AdminRuns() {
[runs, gameMap] [runs, gameMap]
) )
const runOwners = useMemo(() => {
const owners = new Map<string, string>()
let hasUnowned = false
for (const r of runs) {
if (r.owner) {
owners.set(r.owner.id, r.owner.displayName ?? r.owner.id)
} else {
hasUnowned = true
}
}
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
return { owners: sorted, hasUnowned }
}, [runs])
const columns: Column<NuzlockeRun>[] = [ const columns: Column<NuzlockeRun>[] = [
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name }, { header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
{ {
@@ -40,6 +62,15 @@ export function AdminRuns() {
accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`, accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
sortKey: (r) => gameMap.get(r.gameId) ?? '', sortKey: (r) => gameMap.get(r.gameId) ?? '',
}, },
{
header: 'Owner',
accessor: (r) => (
<span className={r.owner ? '' : 'text-text-tertiary'}>
{r.owner?.displayName ?? r.owner?.id ?? 'No owner'}
</span>
),
sortKey: (r) => r.owner?.displayName ?? r.owner?.id ?? '',
},
{ {
header: 'Status', header: 'Status',
accessor: (r) => ( accessor: (r) => (
@@ -93,11 +124,25 @@ export function AdminRuns() {
</option> </option>
))} ))}
</select> </select>
{(statusFilter || gameFilter) && ( <select
value={ownerFilter}
onChange={(e) => setOwnerFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
>
<option value="">All owners</option>
{runOwners.hasUnowned && <option value="__none__">No owner</option>}
{runOwners.owners.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || gameFilter || ownerFilter) && (
<button <button
onClick={() => { onClick={() => {
setStatusFilter('') setStatusFilter('')
setGameFilter('') setGameFilter('')
setOwnerFilter('')
}} }}
className="text-sm text-text-tertiary hover:text-text-primary" className="text-sm text-text-tertiary hover:text-text-primary"
> >

View File

@@ -320,6 +320,11 @@ export interface GenlockeStats {
totalLegs: number totalLegs: number
} }
export interface GenlockeOwner {
id: string
displayName: string | null
}
export interface GenlockeListItem { export interface GenlockeListItem {
id: number id: number
name: string name: string
@@ -328,6 +333,7 @@ export interface GenlockeListItem {
totalLegs: number totalLegs: number
completedLegs: number completedLegs: number
currentLegOrder: number | null currentLegOrder: number | null
owner: GenlockeOwner | null
} }
export interface RetiredPokemon { export interface RetiredPokemon {