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:
@@ -17,9 +17,9 @@ GAME_PAYLOAD = {
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def game(auth_client: AsyncClient) -> dict:
|
||||
async def game(admin_client: AsyncClient) -> dict:
|
||||
"""A game created via the API (no version_group_id)."""
|
||||
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||
response = await admin_client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
@@ -68,8 +68,8 @@ class TestListGames:
|
||||
|
||||
|
||||
class TestCreateGame:
|
||||
async def test_creates_and_returns_game(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||
async def test_creates_and_returns_game(self, admin_client: AsyncClient):
|
||||
response = await admin_client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Pokemon Red"
|
||||
@@ -77,15 +77,15 @@ class TestCreateGame:
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_duplicate_slug_returns_409(
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
self, admin_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
response = await admin_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
|
||||
async def test_missing_required_field_returns_422(self, admin_client: AsyncClient):
|
||||
response = await admin_client.post(BASE, json={"name": "Pokemon Red"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@@ -115,35 +115,35 @@ class TestGetGame:
|
||||
|
||||
|
||||
class TestUpdateGame:
|
||||
async def test_updates_name(self, auth_client: AsyncClient, game: dict):
|
||||
response = await auth_client.put(
|
||||
async def test_updates_name(self, admin_client: AsyncClient, game: dict):
|
||||
response = await admin_client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Pokemon Blue"
|
||||
|
||||
async def test_slug_unchanged_on_partial_update(
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
self, admin_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await auth_client.put(
|
||||
response = await admin_client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "New Name"}
|
||||
)
|
||||
assert response.json()["slug"] == "red"
|
||||
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
async def test_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (
|
||||
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
|
||||
await admin_client.put(f"{BASE}/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
|
||||
await auth_client.post(
|
||||
async def test_duplicate_slug_returns_409(self, admin_client: AsyncClient):
|
||||
await admin_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
|
||||
)
|
||||
r1 = await auth_client.post(
|
||||
r1 = await admin_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
||||
)
|
||||
game_id = r1.json()["id"]
|
||||
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
response = await admin_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@@ -153,13 +153,13 @@ class TestUpdateGame:
|
||||
|
||||
|
||||
class TestDeleteGame:
|
||||
async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
|
||||
response = await auth_client.delete(f"{BASE}/{game['id']}")
|
||||
async def test_deletes_game(self, admin_client: AsyncClient, game: dict):
|
||||
response = await admin_client.delete(f"{BASE}/{game['id']}")
|
||||
assert response.status_code == 204
|
||||
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
assert (await admin_client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.delete(f"{BASE}/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -195,9 +195,9 @@ class TestListByRegion:
|
||||
|
||||
|
||||
class TestCreateRoute:
|
||||
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_creates_route(self, admin_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
response = await auth_client.post(
|
||||
response = await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes",
|
||||
json={"name": "Pallet Town", "order": 1},
|
||||
)
|
||||
@@ -208,35 +208,35 @@ class TestCreateRoute:
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_game_detail_includes_route(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
self, admin_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
await auth_client.post(
|
||||
await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await auth_client.get(f"{BASE}/{game_id}")
|
||||
response = await admin_client.get(f"{BASE}/{game_id}")
|
||||
routes = response.json()["routes"]
|
||||
assert len(routes) == 1
|
||||
assert routes[0]["name"] == "Route 1"
|
||||
|
||||
async def test_game_without_version_group_returns_400(
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
self, admin_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
response = await admin_client.post(
|
||||
f"{BASE}/{game['id']}/routes",
|
||||
json={"name": "Route 1", "order": 1},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_list_routes_excludes_routes_without_encounters(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
self, admin_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
"""list_game_routes only returns routes that have Pokemon encounters."""
|
||||
game_id, _ = game_with_vg
|
||||
await auth_client.post(
|
||||
await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||
response = await admin_client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -248,15 +248,15 @@ class TestCreateRoute:
|
||||
|
||||
class TestUpdateRoute:
|
||||
async def test_updates_route_name(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
self, admin_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await auth_client.post(
|
||||
await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
||||
)
|
||||
).json()
|
||||
response = await auth_client.put(
|
||||
response = await admin_client.put(
|
||||
f"{BASE}/{game_id}/routes/{r['id']}",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
@@ -264,11 +264,11 @@ class TestUpdateRoute:
|
||||
assert response.json()["name"] == "New Name"
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
self, admin_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (
|
||||
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
await admin_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -278,26 +278,26 @@ class TestUpdateRoute:
|
||||
|
||||
|
||||
class TestDeleteRoute:
|
||||
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_deletes_route(self, admin_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await auth_client.post(
|
||||
await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
).json()
|
||||
assert (
|
||||
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
await admin_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
).status_code == 204
|
||||
# No longer in game detail
|
||||
detail = (await auth_client.get(f"{BASE}/{game_id}")).json()
|
||||
detail = (await admin_client.get(f"{BASE}/{game_id}")).json()
|
||||
assert all(route["id"] != r["id"] for route in detail["routes"])
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
self, admin_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (
|
||||
await auth_client.delete(f"{BASE}/{game_id}/routes/9999")
|
||||
await admin_client.delete(f"{BASE}/{game_id}/routes/9999")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -307,20 +307,20 @@ class TestDeleteRoute:
|
||||
|
||||
|
||||
class TestReorderRoutes:
|
||||
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_reorders_routes(self, admin_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r1 = (
|
||||
await auth_client.post(
|
||||
await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
||||
)
|
||||
).json()
|
||||
r2 = (
|
||||
await auth_client.post(
|
||||
await admin_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
||||
)
|
||||
).json()
|
||||
|
||||
response = await auth_client.put(
|
||||
response = await admin_client.put(
|
||||
f"{BASE}/{game_id}/routes/reorder",
|
||||
json={
|
||||
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
|
||||
|
||||
Reference in New Issue
Block a user