feat: auth-aware UI and role-based access control (#67)
All checks were successful
CI / backend-tests (push) Successful in 32s
CI / frontend-tests (push) Successful in 29s

## Summary

- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints

## Test plan

- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
This commit was merged in pull request #67.
This commit is contained in:
2026-03-21 11:44:05 +01:00
committed by TheFurya
parent f7731b0497
commit e8ded9184b
27 changed files with 826 additions and 347 deletions

View File

@@ -29,21 +29,21 @@ CHARMANDER_DATA = {
@pytest.fixture
async def pikachu(client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
async def pikachu(admin_client: AsyncClient) -> dict:
response = await admin_client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def charmander(client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=CHARMANDER_DATA)
async def charmander(admin_client: AsyncClient) -> dict:
response = await admin_client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict:
async def ctx(db_session: AsyncSession, admin_client: AsyncClient) -> dict:
"""Full context: game + route + two pokemon + nuzlocke encounter on pikachu."""
vg = VersionGroup(name="Poke Test VG", slug="poke-test-vg")
db_session.add(vg)
@@ -63,11 +63,11 @@ async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict:
db_session.add(route)
await db_session.flush()
r1 = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
r1 = await admin_client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert r1.status_code == 201
pikachu = r1.json()
r2 = await client.post(POKEMON_BASE, json=CHARMANDER_DATA)
r2 = await admin_client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert r2.status_code == 201
charmander = r2.json()
@@ -146,8 +146,8 @@ class TestListPokemon:
class TestCreatePokemon:
async def test_creates_pokemon(self, client: AsyncClient):
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
async def test_creates_pokemon(self, admin_client: AsyncClient):
response = await admin_client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201
data = response.json()
assert data["name"] == "pikachu"
@@ -156,16 +156,16 @@ class TestCreatePokemon:
assert isinstance(data["id"], int)
async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict
self, admin_client: AsyncClient, pikachu: dict
):
response = await client.post(
response = await admin_client.post(
POKEMON_BASE,
json={**PIKACHU_DATA, "name": "pikachu-copy"},
)
assert response.status_code == 409
async def test_missing_required_returns_422(self, client: AsyncClient):
response = await client.post(POKEMON_BASE, json={"name": "pikachu"})
async def test_missing_required_returns_422(self, admin_client: AsyncClient):
response = await admin_client.post(POKEMON_BASE, json={"name": "pikachu"})
assert response.status_code == 422
@@ -190,25 +190,25 @@ class TestGetPokemon:
class TestUpdatePokemon:
async def test_updates_name(self, client: AsyncClient, pikachu: dict):
response = await client.put(
async def test_updates_name(self, admin_client: AsyncClient, pikachu: dict):
response = await admin_client.put(
f"{POKEMON_BASE}/{pikachu['id']}", json={"name": "Pikachu"}
)
assert response.status_code == 200
assert response.json()["name"] == "Pikachu"
async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.put(
response = await admin_client.put(
f"{POKEMON_BASE}/{pikachu['id']}",
json={"pokeapiId": charmander["pokeapiId"]},
)
assert response.status_code == 409
async def test_not_found_returns_404(self, client: AsyncClient):
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (
await client.put(f"{POKEMON_BASE}/9999", json={"name": "x"})
await admin_client.put(f"{POKEMON_BASE}/9999", json={"name": "x"})
).status_code == 404
@@ -218,22 +218,22 @@ class TestUpdatePokemon:
class TestDeletePokemon:
async def test_deletes_pokemon(self, client: AsyncClient, charmander: dict):
async def test_deletes_pokemon(self, admin_client: AsyncClient, charmander: dict):
assert (
await client.delete(f"{POKEMON_BASE}/{charmander['id']}")
await admin_client.delete(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 204
assert (
await client.get(f"{POKEMON_BASE}/{charmander['id']}")
await admin_client.get(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{POKEMON_BASE}/9999")).status_code == 404
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.delete(f"{POKEMON_BASE}/9999")).status_code == 404
async def test_pokemon_with_encounters_returns_409(
self, client: AsyncClient, ctx: dict
self, admin_client: AsyncClient, ctx: dict
):
"""Pokemon referenced by a nuzlocke encounter cannot be deleted."""
response = await client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}")
response = await admin_client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}")
assert response.status_code == 409
@@ -249,9 +249,9 @@ class TestPokemonFamilies:
assert response.json()["families"] == []
async def test_returns_family_grouping(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -259,7 +259,7 @@ class TestPokemonFamilies:
"trigger": "level-up",
},
)
response = await client.get(f"{POKEMON_BASE}/families")
response = await admin_client.get(f"{POKEMON_BASE}/families")
assert response.status_code == 200
families = response.json()["families"]
assert len(families) == 1
@@ -280,9 +280,9 @@ class TestPokemonEvolutionChain:
assert response.json() == []
async def test_returns_chain_for_multi_stage(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -290,7 +290,7 @@ class TestPokemonEvolutionChain:
"trigger": "level-up",
},
)
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
response = await admin_client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
assert response.status_code == 200
chain = response.json()
assert len(chain) == 1
@@ -317,9 +317,9 @@ class TestListEvolutions:
assert data["total"] == 0
async def test_returns_created_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -327,14 +327,14 @@ class TestListEvolutions:
"trigger": "level-up",
},
)
response = await client.get(EVO_BASE)
response = await admin_client.get(EVO_BASE)
assert response.status_code == 200
assert response.json()["total"] == 1
async def test_filter_by_trigger(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -342,9 +342,9 @@ class TestListEvolutions:
"trigger": "use-item",
},
)
hit = await client.get(EVO_BASE, params={"trigger": "use-item"})
hit = await admin_client.get(EVO_BASE, params={"trigger": "use-item"})
assert hit.json()["total"] == 1
miss = await client.get(EVO_BASE, params={"trigger": "level-up"})
miss = await admin_client.get(EVO_BASE, params={"trigger": "level-up"})
assert miss.json()["total"] == 0
@@ -355,9 +355,9 @@ class TestListEvolutions:
class TestCreateEvolution:
async def test_creates_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.post(
response = await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -374,9 +374,9 @@ class TestCreateEvolution:
assert data["toPokemon"]["name"] == "charmander"
async def test_invalid_from_pokemon_returns_404(
self, client: AsyncClient, charmander: dict
self, admin_client: AsyncClient, charmander: dict
):
response = await client.post(
response = await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": 9999,
@@ -387,9 +387,9 @@ class TestCreateEvolution:
assert response.status_code == 404
async def test_invalid_to_pokemon_returns_404(
self, client: AsyncClient, pikachu: dict
self, admin_client: AsyncClient, pikachu: dict
):
response = await client.post(
response = await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -408,9 +408,9 @@ class TestCreateEvolution:
class TestUpdateEvolution:
@pytest.fixture
async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
) -> dict:
response = await client.post(
response = await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -420,16 +420,16 @@ class TestUpdateEvolution:
)
return response.json()
async def test_updates_trigger(self, client: AsyncClient, evolution: dict):
response = await client.put(
async def test_updates_trigger(self, admin_client: AsyncClient, evolution: dict):
response = await admin_client.put(
f"{EVO_BASE}/{evolution['id']}", json={"trigger": "use-item"}
)
assert response.status_code == 200
assert response.json()["trigger"] == "use-item"
async def test_not_found_returns_404(self, client: AsyncClient):
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (
await client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"})
await admin_client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"})
).status_code == 404
@@ -441,9 +441,9 @@ class TestUpdateEvolution:
class TestDeleteEvolution:
@pytest.fixture
async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
self, admin_client: AsyncClient, pikachu: dict, charmander: dict
) -> dict:
response = await client.post(
response = await admin_client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
@@ -453,12 +453,12 @@ class TestDeleteEvolution:
)
return response.json()
async def test_deletes_evolution(self, client: AsyncClient, evolution: dict):
assert (await client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204
assert (await client.get(EVO_BASE)).json()["total"] == 0
async def test_deletes_evolution(self, admin_client: AsyncClient, evolution: dict):
assert (await admin_client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204
assert (await admin_client.get(EVO_BASE)).json()["total"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{EVO_BASE}/9999")).status_code == 404
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.delete(f"{EVO_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
@@ -467,13 +467,13 @@ class TestDeleteEvolution:
class TestRouteEncounters:
async def test_empty_list_for_route(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
async def test_empty_list_for_route(self, admin_client: AsyncClient, ctx: dict):
response = await admin_client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
assert response.status_code == 200
assert response.json() == []
async def test_creates_route_encounter(self, client: AsyncClient, ctx: dict):
response = await client.post(
async def test_creates_route_encounter(self, admin_client: AsyncClient, ctx: dict):
response = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
@@ -490,8 +490,8 @@ class TestRouteEncounters:
assert data["encounterRate"] == 10
assert data["pokemon"]["name"] == "charmander"
async def test_invalid_route_returns_404(self, client: AsyncClient, ctx: dict):
response = await client.post(
async def test_invalid_route_returns_404(self, admin_client: AsyncClient, ctx: dict):
response = await admin_client.post(
f"{ROUTE_BASE}/9999/pokemon",
json={
"pokemonId": ctx["charmander_id"],
@@ -504,8 +504,8 @@ class TestRouteEncounters:
)
assert response.status_code == 404
async def test_invalid_pokemon_returns_404(self, client: AsyncClient, ctx: dict):
response = await client.post(
async def test_invalid_pokemon_returns_404(self, admin_client: AsyncClient, ctx: dict):
response = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": 9999,
@@ -518,8 +518,8 @@ class TestRouteEncounters:
)
assert response.status_code == 404
async def test_updates_route_encounter(self, client: AsyncClient, ctx: dict):
r = await client.post(
async def test_updates_route_encounter(self, admin_client: AsyncClient, ctx: dict):
r = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
@@ -531,23 +531,23 @@ class TestRouteEncounters:
},
)
enc = r.json()
response = await client.put(
response = await admin_client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}",
json={"encounterRate": 25},
)
assert response.status_code == 200
assert response.json()["encounterRate"] == 25
async def test_update_not_found_returns_404(self, client: AsyncClient, ctx: dict):
async def test_update_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
assert (
await client.put(
await admin_client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999",
json={"encounterRate": 5},
)
).status_code == 404
async def test_deletes_route_encounter(self, client: AsyncClient, ctx: dict):
r = await client.post(
async def test_deletes_route_encounter(self, admin_client: AsyncClient, ctx: dict):
r = await admin_client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
@@ -560,13 +560,13 @@ class TestRouteEncounters:
)
enc = r.json()
assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}")
await admin_client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}")
).status_code == 204
assert (
await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
await admin_client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
).json() == []
async def test_delete_not_found_returns_404(self, client: AsyncClient, ctx: dict):
async def test_delete_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999")
await admin_client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999")
).status_code == 404