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

View File

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

View File

@@ -1,3 +1,5 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import delete as sa_delete
@@ -6,16 +8,17 @@ from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.auth import AuthUser, get_current_user, require_auth, require_run_owner
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.genlocke_transfer import GenlockeTransfer
from app.models.nuzlocke_run import NuzlockeRun
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.user import User
from app.schemas.genlocke import (
AddLegRequest,
AdvanceLegRequest,
@@ -25,6 +28,7 @@ from app.schemas.genlocke import (
GenlockeLegDetailResponse,
GenlockeLineageResponse,
GenlockeListItem,
GenlockeOwnerResponse,
GenlockeResponse,
GenlockeStatsResponse,
GenlockeUpdate,
@@ -41,12 +45,72 @@ from app.services.families import build_families, resolve_base_form
router = APIRouter()
async def _check_genlocke_owner(
genlocke_id: int,
user: AuthUser,
session: AsyncSession,
) -> None:
"""
Verify user owns the genlocke via the first leg's run.
Raises 404 if the genlocke doesn't exist.
Raises 403 if the first leg has a run with a different owner.
Raises 403 if the first leg has an unowned run (read-only legacy data).
"""
# First check if genlocke exists
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
leg_result = await session.execute(
select(GenlockeLeg)
.where(GenlockeLeg.genlocke_id == genlocke_id, GenlockeLeg.leg_order == 1)
.options(selectinload(GenlockeLeg.run))
)
first_leg = leg_result.scalar_one_or_none()
if first_leg is None or first_leg.run is None:
raise HTTPException(
status_code=403,
detail="Cannot modify genlocke: no run found for first leg",
)
require_run_owner(first_leg.run, user)
def _is_genlocke_visible(genlocke: Genlocke, user: AuthUser | None) -> bool:
"""
Check if a genlocke is visible to the given user.
Visibility is inferred from the first leg's run:
- Public runs are visible to everyone
- Private runs are only visible to the owner
"""
first_leg = next((leg for leg in genlocke.legs if leg.leg_order == 1), None)
if not first_leg or not first_leg.run:
# No first leg or run - treat as visible (legacy data)
return True
if first_leg.run.visibility == RunVisibility.PUBLIC:
return True
# Private run - only visible to owner
if user is None:
return False
if first_leg.run.owner_id is None:
return False
return str(first_leg.run.owner_id) == user.id
@router.get("", response_model=list[GenlockeListItem])
async def list_genlockes(session: AsyncSession = Depends(get_session)):
async def list_genlockes(
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
result = await session.execute(
select(Genlocke)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
selectinload(Genlocke.legs)
.selectinload(GenlockeLeg.run)
.selectinload(NuzlockeRun.owner),
)
.order_by(Genlocke.created_at.desc())
)
@@ -54,8 +118,22 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
items = []
for g in genlockes:
# Filter out private genlockes for non-owners
if not _is_genlocke_visible(g, user):
continue
completed_legs = 0
current_leg_order = None
owner = None
# Find first leg (leg_order == 1) to get owner
first_leg = next((leg for leg in g.legs if leg.leg_order == 1), None)
if first_leg and first_leg.run and first_leg.run.owner:
owner = GenlockeOwnerResponse(
id=first_leg.run.owner.id,
display_name=first_leg.run.owner.display_name,
)
for leg in g.legs:
if leg.run and leg.run.status == "completed":
completed_legs += 1
@@ -71,13 +149,18 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
total_legs=len(g.legs),
completed_legs=completed_legs,
current_leg_order=current_leg_order,
owner=owner,
)
)
return items
@router.get("/{genlocke_id}", response_model=GenlockeDetailResponse)
async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_session)):
async def get_genlocke(
genlocke_id: int,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
@@ -90,6 +173,10 @@ async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_ses
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
# Check visibility - return 404 for private genlockes to non-owners
if not _is_genlocke_visible(genlocke, user):
raise HTTPException(status_code=404, detail="Genlocke not found")
# Collect run IDs for aggregate query
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
@@ -173,20 +260,26 @@ async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_ses
response_model=GenlockeGraveyardResponse,
)
async def get_genlocke_graveyard(
genlocke_id: int, session: AsyncSession = Depends(get_session)
genlocke_id: int,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
# Load genlocke with legs + game
# Load genlocke with legs + game + run (for visibility check)
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
)
)
genlocke = result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
if not _is_genlocke_visible(genlocke, user):
raise HTTPException(status_code=404, detail="Genlocke not found")
# Build run_id → (leg_order, game_name) lookup
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
run_lookup: dict[int, tuple[int, str]] = {}
@@ -274,20 +367,26 @@ async def get_genlocke_graveyard(
response_model=GenlockeLineageResponse,
)
async def get_genlocke_lineages(
genlocke_id: int, session: AsyncSession = Depends(get_session)
genlocke_id: int,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
# Load genlocke with legs + game
# Load genlocke with legs + game + run (for visibility check)
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
)
)
genlocke = result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
if not _is_genlocke_visible(genlocke, user):
raise HTTPException(status_code=404, detail="Genlocke not found")
# Query all transfers for this genlocke
transfer_result = await session.execute(
select(GenlockeTransfer).where(GenlockeTransfer.genlocke_id == genlocke_id)
@@ -440,7 +539,7 @@ async def get_genlocke_lineages(
async def create_genlocke(
data: GenlockeCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
if not data.game_ids:
raise HTTPException(status_code=400, detail="At least one game is required")
@@ -455,6 +554,13 @@ async def create_genlocke(
if missing:
raise HTTPException(status_code=404, detail=f"Games not found: {missing}")
# Ensure user exists in local DB
user_id = UUID(user.id)
db_user = await session.get(User, user_id)
if db_user is None:
db_user = User(id=user_id, email=user.email or "")
session.add(db_user)
# Create genlocke
genlocke = Genlocke(
name=data.name.strip(),
@@ -481,6 +587,7 @@ async def create_genlocke(
first_game = found_games[data.game_ids[0]]
first_run = NuzlockeRun(
game_id=first_game.id,
owner_id=user_id,
name=f"{data.name.strip()} \u2014 Leg 1",
status="active",
rules=data.nuzlocke_rules,
@@ -513,15 +620,23 @@ async def get_leg_survivors(
genlocke_id: int,
leg_order: int,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
# Find the leg
result = await session.execute(
select(GenlockeLeg).where(
GenlockeLeg.genlocke_id == genlocke_id,
GenlockeLeg.leg_order == leg_order,
)
# Load genlocke with legs + run for visibility check
genlocke_result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(selectinload(Genlocke.legs).selectinload(GenlockeLeg.run))
)
leg = result.scalar_one_or_none()
genlocke = genlocke_result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
if not _is_genlocke_visible(genlocke, user):
raise HTTPException(status_code=404, detail="Genlocke not found")
# Find the leg
leg = next((leg for leg in genlocke.legs if leg.leg_order == leg_order), None)
if leg is None:
raise HTTPException(status_code=404, detail="Leg not found")
@@ -571,8 +686,10 @@ async def advance_leg(
leg_order: int,
data: AdvanceLegRequest | None = None,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
# Load genlocke with legs
result = await session.execute(
select(Genlocke)
@@ -653,9 +770,10 @@ async def advance_leg(
else:
current_leg.retired_pokemon_ids = []
# Create a new run for the next leg
# Create a new run for the next leg, preserving owner from current run
new_run = NuzlockeRun(
game_id=next_leg.game_id,
owner_id=current_run.owner_id,
name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}",
status="active",
rules=genlocke.nuzlocke_rules,
@@ -786,12 +904,21 @@ class RetiredFamiliesResponse(BaseModel):
async def get_retired_families(
genlocke_id: int,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
# Verify genlocke exists
genlocke = await session.get(Genlocke, genlocke_id)
# Load genlocke with legs + run for visibility check
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(selectinload(Genlocke.legs).selectinload(GenlockeLeg.run))
)
genlocke = result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
if not _is_genlocke_visible(genlocke, user):
raise HTTPException(status_code=404, detail="Genlocke not found")
# Query all legs with retired_pokemon_ids
result = await session.execute(
select(GenlockeLeg)
@@ -826,8 +953,10 @@ async def update_genlocke(
genlocke_id: int,
data: GenlockeUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
@@ -863,8 +992,10 @@ async def update_genlocke(
async def delete_genlocke(
genlocke_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
@@ -895,8 +1026,10 @@ async def add_leg(
genlocke_id: int,
data: AddLegRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
@@ -938,8 +1071,10 @@ async def remove_leg(
genlocke_id: int,
leg_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
result = await session.execute(
select(GenlockeLeg).where(
GenlockeLeg.id == leg_id,

View File

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

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_session
from app.models.nuzlocke_run import NuzlockeRun
from app.models.user import User
@@ -105,3 +106,20 @@ async def require_admin(
detail="Admin access required",
)
return user
def require_run_owner(run: NuzlockeRun, user: AuthUser) -> None:
"""
Verify user owns the run. Raises 403 if not owner.
Unowned (legacy) runs are read-only and reject all mutations.
"""
if run.owner_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This run has no owner and cannot be modified",
)
if UUID(user.id) != run.owner_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the run owner can perform this action",
)

View File

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

View File

@@ -1,10 +1,13 @@
"""Integration tests for the Genlockes & Bosses API."""
import pytest
from httpx import AsyncClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import AuthUser, get_current_user
from app.main import app
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.version_group import VersionGroup
@@ -55,7 +58,9 @@ async def games_ctx(db_session: AsyncSession) -> dict:
@pytest.fixture
async def ctx(db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict) -> dict:
async def ctx(
db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict
) -> dict:
"""Full context: routes + pokemon + genlocke + encounter for advance/transfer tests."""
route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1)
route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1)
@@ -116,6 +121,178 @@ class TestListGenlockes:
assert "Test Genlocke" in names
# ---------------------------------------------------------------------------
# Genlockes — visibility (inferred from first leg's run)
# ---------------------------------------------------------------------------
class TestGenlockeVisibility:
"""Test that genlocke visibility is inferred from the first leg's run."""
@pytest.fixture
async def private_genlocke_ctx(
self, db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict
) -> dict:
"""Create a genlocke and make its first leg's run private."""
r = await admin_client.post(
GENLOCKES_BASE,
json={
"name": "Private Genlocke",
"gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]],
},
)
assert r.status_code == 201
genlocke = r.json()
leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1)
run_id = leg1["runId"]
# Make the run private
run = await db_session.get(NuzlockeRun, run_id)
assert run is not None
run.visibility = RunVisibility.PRIVATE
await db_session.commit()
return {
**games_ctx,
"genlocke_id": genlocke["id"],
"run_id": run_id,
"owner_id": str(run.owner_id),
}
async def test_private_genlocke_hidden_from_unauthenticated_list(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""Unauthenticated users should not see private genlockes in the list."""
# Temporarily remove auth override to simulate unauthenticated request
app.dependency_overrides.pop(get_current_user, None)
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as unauth_client:
response = await unauth_client.get(GENLOCKES_BASE)
assert response.status_code == 200
names = [g["name"] for g in response.json()]
assert "Private Genlocke" not in names
finally:
pass
async def test_private_genlocke_visible_to_owner_in_list(
self, admin_client: AsyncClient, private_genlocke_ctx: dict
):
"""Owner should still see their private genlocke in the list."""
response = await admin_client.get(GENLOCKES_BASE)
assert response.status_code == 200
names = [g["name"] for g in response.json()]
assert "Private Genlocke" in names
async def test_private_genlocke_404_for_unauthenticated_get(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""Unauthenticated users should get 404 for private genlocke details."""
app.dependency_overrides.pop(get_current_user, None)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as unauth_client:
response = await unauth_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
)
assert response.status_code == 404
async def test_private_genlocke_accessible_to_owner(
self, admin_client: AsyncClient, private_genlocke_ctx: dict
):
"""Owner should still be able to access their private genlocke."""
response = await admin_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
)
assert response.status_code == 200
assert response.json()["name"] == "Private Genlocke"
async def test_private_genlocke_graveyard_404_for_unauthenticated(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""Unauthenticated users should get 404 for private genlocke graveyard."""
app.dependency_overrides.pop(get_current_user, None)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as unauth_client:
response = await unauth_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/graveyard"
)
assert response.status_code == 404
async def test_private_genlocke_lineages_404_for_unauthenticated(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""Unauthenticated users should get 404 for private genlocke lineages."""
app.dependency_overrides.pop(get_current_user, None)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as unauth_client:
response = await unauth_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/lineages"
)
assert response.status_code == 404
async def test_private_genlocke_survivors_404_for_unauthenticated(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""Unauthenticated users should get 404 for private genlocke survivors."""
app.dependency_overrides.pop(get_current_user, None)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as unauth_client:
response = await unauth_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/legs/1/survivors"
)
assert response.status_code == 404
async def test_private_genlocke_retired_families_404_for_unauthenticated(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""Unauthenticated users should get 404 for private retired-families."""
app.dependency_overrides.pop(get_current_user, None)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as unauth_client:
response = await unauth_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/retired-families"
)
assert response.status_code == 404
async def test_private_genlocke_404_for_different_user(
self, db_session: AsyncSession, private_genlocke_ctx: dict
):
"""A different authenticated user should get 404 for private genlockes."""
# Create a different user's auth
different_user = AuthUser(
id="00000000-0000-4000-a000-000000000099",
email="other@example.com",
role="authenticated",
)
def _override():
return different_user
app.dependency_overrides[get_current_user] = _override
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as other_client:
response = await other_client.get(
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
)
assert response.status_code == 404
# Also check list
list_response = await other_client.get(GENLOCKES_BASE)
assert list_response.status_code == 200
names = [g["name"] for g in list_response.json()]
assert "Private Genlocke" not in names
finally:
app.dependency_overrides.pop(get_current_user, None)
# ---------------------------------------------------------------------------
# Genlockes — create
# ---------------------------------------------------------------------------
@@ -259,14 +436,18 @@ class TestGenlockeLegs:
class TestAdvanceLeg:
async def test_uncompleted_run_returns_400(self, admin_client: AsyncClient, ctx: dict):
async def test_uncompleted_run_returns_400(
self, admin_client: AsyncClient, ctx: dict
):
"""Cannot advance when leg 1's run is still active."""
response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
)
assert response.status_code == 400
async def test_no_next_leg_returns_400(self, admin_client: AsyncClient, games_ctx: dict):
async def test_no_next_leg_returns_400(
self, admin_client: AsyncClient, games_ctx: dict
):
"""A single-leg genlocke cannot be advanced."""
r = await admin_client.post(
GENLOCKES_BASE,
@@ -283,7 +464,9 @@ class TestAdvanceLeg:
async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict):
"""Completing the current run allows advancing to the next leg."""
await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
await admin_client.patch(
f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}
)
response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
@@ -295,7 +478,9 @@ class TestAdvanceLeg:
async def test_advances_with_transfers(self, admin_client: AsyncClient, ctx: dict):
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg."""
await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
await admin_client.patch(
f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}
)
response = await admin_client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
@@ -319,30 +504,40 @@ class TestAdvanceLeg:
class TestGenlockeGraveyard:
async def test_returns_empty_graveyard(self, admin_client: AsyncClient, ctx: dict):
response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard")
response = await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard"
)
assert response.status_code == 200
data = response.json()
assert data["entries"] == []
assert data["totalDeaths"] == 0
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404
assert (
await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")
).status_code == 404
class TestGenlockeLineages:
async def test_returns_empty_lineages(self, admin_client: AsyncClient, ctx: dict):
response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages")
response = await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages"
)
assert response.status_code == 200
data = response.json()
assert data["lineages"] == []
assert data["totalLineages"] == 0
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404
assert (
await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")
).status_code == 404
class TestGenlockeRetiredFamilies:
async def test_returns_empty_retired_families(self, admin_client: AsyncClient, ctx: dict):
async def test_returns_empty_retired_families(
self, admin_client: AsyncClient, ctx: dict
):
response = await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
)
@@ -365,9 +560,13 @@ class TestLegSurvivors:
assert response.status_code == 200
assert len(response.json()) == 1
async def test_leg_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
async def test_leg_not_found_returns_404(
self, admin_client: AsyncClient, ctx: dict
):
assert (
await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors")
await admin_client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors"
)
).status_code == 404
@@ -386,7 +585,9 @@ BOSS_PAYLOAD = {
class TestBossCRUD:
async def test_empty_list(self, admin_client: AsyncClient, games_ctx: dict):
response = await admin_client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
response = await admin_client.get(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses"
)
assert response.status_code == 200
assert response.json() == []
@@ -441,7 +642,9 @@ class TestBossCRUD:
async def test_invalid_game_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404
async def test_game_without_version_group_returns_400(self, admin_client: AsyncClient):
async def test_game_without_version_group_returns_400(
self, admin_client: AsyncClient
):
game = (
await admin_client.post(
GAMES_BASE,
@@ -480,7 +683,9 @@ class TestBossResults:
return {"boss_id": boss["id"], "run_id": run["id"]}
async def test_empty_list(self, admin_client: AsyncClient, boss_ctx: dict):
response = await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
response = await admin_client.get(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results"
)
assert response.status_code == 200
assert response.json() == []
@@ -495,7 +700,9 @@ class TestBossResults:
assert data["attempts"] == 1
assert data["completedAt"] is not None
async def test_upserts_existing_result(self, admin_client: AsyncClient, boss_ctx: dict):
async def test_upserts_existing_result(
self, admin_client: AsyncClient, boss_ctx: dict
):
"""POSTing the same boss twice updates the result (upsert)."""
await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
@@ -530,10 +737,16 @@ class TestBossResults:
await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json() == []
async def test_invalid_run_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
assert (await admin_client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404
async def test_invalid_run_returns_404(
self, admin_client: AsyncClient, boss_ctx: dict
):
assert (
await admin_client.get(f"{RUNS_BASE}/9999/boss-results")
).status_code == 404
async def test_invalid_boss_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
async def test_invalid_boss_returns_404(
self, admin_client: AsyncClient, boss_ctx: dict
):
response = await admin_client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": 9999, "result": "won"},
@@ -587,8 +800,16 @@ class TestExport:
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_game_routes_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404
async def test_export_game_routes_not_found_returns_404(
self, admin_client: AsyncClient
):
assert (
await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")
).status_code == 404
async def test_export_game_bosses_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404
async def test_export_game_bosses_not_found_returns_404(
self, admin_client: AsyncClient
):
assert (
await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")
).status_code == 404

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

View File

@@ -67,7 +67,7 @@ export function RunDashboard() {
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const isOwner = user && run?.owner?.id === user.id
const canEdit = isOwner || !run?.owner
const canEdit = isOwner
const encounters = run?.encounters ?? []
const alive = useMemo(
@@ -143,6 +143,32 @@ export function RunDashboard() {
</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 */}
{!isActive && (
<div

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes'
import { useGameRoutes } from '../hooks/useGames'
@@ -286,7 +287,7 @@ interface RouteGroupProps {
giftEncounterByRoute: Map<number, EncounterDetail>
isExpanded: boolean
onToggleExpand: () => void
onRouteClick: (route: Route) => void
onRouteClick: ((route: Route) => void) | undefined
filter: 'all' | RouteStatus
pinwheelClause: boolean
}
@@ -438,10 +439,12 @@ function RouteGroup({
<button
key={child.id}
type="button"
onClick={() => !isDisabled && onRouteClick(child)}
disabled={isDisabled}
onClick={() => !isDisabled && onRouteClick?.(child)}
disabled={isDisabled || !onRouteClick}
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}`}
>
<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: bossResults } = useBossResults(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 [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
@@ -944,7 +951,7 @@ export function RunEncounters() {
)}
</div>
<div className="flex items-center gap-2">
{isActive && run.rules?.shinyClause && (
{isActive && canEdit && run.rules?.shinyClause && (
<button
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"
@@ -952,7 +959,7 @@ export function RunEncounters() {
&#10022; Log Shiny
</button>
)}
{isActive && (
{isActive && canEdit && (
<button
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"
@@ -960,7 +967,7 @@ export function RunEncounters() {
&#x1F95A; Log Egg
</button>
)}
{isActive && (
{isActive && canEdit && (
<button
onClick={() => setShowEndRun(true)}
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>
{/* 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 */}
{!isActive && (
<div
@@ -1025,7 +1058,7 @@ export function RunEncounters() {
</p>
</div>
</div>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && canEdit && (
<button
onClick={() => {
if (hofTeam && hofTeam.length > 0) {
@@ -1063,13 +1096,15 @@ export function RunEncounters() {
<span className="text-xs font-medium text-text-link uppercase tracking-wider">
Hall of Fame
</span>
<button
type="button"
onClick={() => setShowHofModal(true)}
className="text-xs text-blue-400 hover:text-accent-300"
>
{hofTeam ? 'Edit' : 'Select team'}
</button>
{canEdit && (
<button
type="button"
onClick={() => setShowHofModal(true)}
className="text-xs text-blue-400 hover:text-accent-300"
>
{hofTeam ? 'Edit' : 'Select team'}
</button>
)}
</div>
{hofTeam ? (
<div className="flex gap-2 flex-wrap">
@@ -1262,7 +1297,9 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
@@ -1276,7 +1313,9 @@ export function RunEncounters() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
@@ -1292,7 +1331,9 @@ export function RunEncounters() {
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
onEncounterClick={
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
/>
</div>
)}
@@ -1306,7 +1347,7 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1318,7 +1359,7 @@ export function RunEncounters() {
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && (
{isActive && canEdit && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
@@ -1409,7 +1450,7 @@ export function RunEncounters() {
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
onRouteClick={canEdit ? handleRouteClick : undefined}
filter={filter}
pinwheelClause={pinwheelClause}
/>
@@ -1425,8 +1466,9 @@ export function RunEncounters() {
<button
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
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}`}
onClick={canEdit ? () => handleRouteClick(route) : undefined}
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}`} />
<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">
Defeated &#10003;
</span>
) : isActive ? (
) : isActive && canEdit ? (
<button
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"
@@ -1593,35 +1635,44 @@ export function RunEncounters() {
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Player team snapshot */}
{isDefeated && (() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm: BossResultTeamMember) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
) : (
<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>
)
})}
{isDefeated &&
(() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">
Your Team
</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm: BossResultTeamMember) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img
src={dp.spriteUrl}
alt={dp.name}
className="w-10 h-10"
/>
) : (
<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>
{sectionAfter && (
<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 [statusFilter, setStatusFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const filtered = useMemo(() => {
if (!statusFilter) return genlockes
return genlockes.filter((g) => g.status === statusFilter)
}, [genlockes, statusFilter])
let result = genlockes
if (statusFilter) result = result.filter((g) => g.status === 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>[] = [
{ 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',
accessor: (g) => (
@@ -67,9 +99,25 @@ export function AdminGenlockes() {
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</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
onClick={() => setStatusFilter('')}
onClick={() => {
setStatusFilter('')
setOwnerFilter('')
}}
className="text-sm text-text-tertiary hover:text-text-primary"
>
Clear filters

View File

@@ -13,6 +13,7 @@ export function AdminRuns() {
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
const [statusFilter, setStatusFilter] = useState('')
const [gameFilter, setGameFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
@@ -20,8 +21,15 @@ export function AdminRuns() {
let result = runs
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
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
}, [runs, statusFilter, gameFilter])
}, [runs, statusFilter, gameFilter, ownerFilter])
const runGames = useMemo(
() =>
@@ -33,6 +41,20 @@ export function AdminRuns() {
[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>[] = [
{ 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}`,
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',
accessor: (r) => (
@@ -93,11 +124,25 @@ export function AdminRuns() {
</option>
))}
</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
onClick={() => {
setStatusFilter('')
setGameFilter('')
setOwnerFilter('')
}}
className="text-sm text-text-tertiary hover:text-text-primary"
>

View File

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