feat: auth-aware UI and role-based access control (#67)
## 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user