bambuddy/backend/tests/integration/test_groups_api.py
maziggy cecdf8f5a7 feat(auth): permission-delegated Settings + Group editor routes; fix group-edit cache stale-read (#1083)
Three intertwined changes, split by intent:

  1. Swap AdminRoute for PermissionRoute on /settings, /groups/new, and
     /groups/:id/edit. Admins retain full access; non-admin users whose
     group holds settings:read / groups:create / groups:update can now
     enter the respective pages instead of being silently redirected to
     the dashboard. SettingsPage's individual tabs and cards keep their
     existing per-action permission checks, so tabs a delegated user can't
     use stay hidden or disabled. AdminRoute had no other callers and is
     removed.

  2. Fix #1083: editing a custom group's permissions appeared to revert
     on reopen. The backend PATCH was persisting correctly — four new
     integration tests in test_groups_api.py (including a direct DB read
     after PATCH) confirm persistence, empty-list clear, preserve-on-
     absent, and 400 on bogus permission. The actual bug was a stale
     ['group', id] React Query cache: onSuccess invalidated ['groups']
     but not the detail key, so the 60s global staleTime served the pre-
     update body on re-mount. onSuccess now primes ['group', id] with the
     PATCH response body (invalidation is not enough — it races with the
     refetch). Frontend regression test added.

  3. Delegated users with settings:read but not settings:update no longer
     get an infinite loop of failed-save toasts on Settings. The debounced
     auto-save effect fires PATCH /settings whenever localSettings diverges
     from the server snapshot; without a permission gate this produced an
     endless 403 → toast → re-render → effect → 403 loop. Three gates now:
     the updateSetting callback short-circuits with a single toast before
     localSettings diverges, the effect safety-nets the same check in case
     any call site bypasses updateSetting, and the language <select> (the
     only direct api.updateSettings bypass in the file) now routes through
     updateMutation with the same guard. New settings.toast.noPermissionUpdate
     key translated in all 8 locales.

  Scoping note: an earlier iteration of change #3 included a
  localSettings rollback inside updateMutation.onError — removed in
  review because it would have discarded in-progress admin typing on
  any transient network/server error. The three up-front guards make
  the rollback unnecessary for the permission case (mutation never
  fires), and preserving typed-in values on transient failures is the
  right call for admins.
2026-04-22 19:10:18 +02:00

134 lines
4.6 KiB
Python

"""Integration tests for the /api/v1/groups/* endpoints.
Issue #1083: updates to a group's permission list must persist across GET,
regardless of whether the frontend invalidates its React Query cache.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.app.models.group import Group
async def _setup_admin(async_client: AsyncClient) -> dict[str, str]:
await async_client.post(
"/api/v1/auth/setup",
json={"auth_enabled": True, "admin_username": "gadmin", "admin_password": "AdminPass1!"},
)
resp = await async_client.post(
"/api/v1/auth/login",
json={"username": "gadmin", "password": "AdminPass1!"},
)
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_group_permissions_persists(async_client: AsyncClient, db_session):
"""PATCH /groups/{id} with a new permissions list must persist to DB (#1083)."""
headers = await _setup_admin(async_client)
create = await async_client.post(
"/api/v1/groups/",
headers=headers,
json={
"name": "test_perms",
"permissions": ["printers:read", "archives:read", "queue:read", "inventory:read"],
},
)
assert create.status_code == 201
gid = create.json()["id"]
# Update to a wholly different set
update = await async_client.patch(
f"/api/v1/groups/{gid}",
headers=headers,
json={"permissions": ["users:read", "groups:read"]},
)
assert update.status_code == 200
assert sorted(update.json()["permissions"]) == ["groups:read", "users:read"]
# Re-read via API — must reflect the update, not the creation
got = await async_client.get(f"/api/v1/groups/{gid}", headers=headers)
assert got.status_code == 200
assert sorted(got.json()["permissions"]) == ["groups:read", "users:read"]
# Direct DB read — same expectation
result = await db_session.execute(select(Group).where(Group.id == gid))
assert sorted(result.scalar_one().permissions or []) == ["groups:read", "users:read"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_group_to_empty_permissions(async_client: AsyncClient, db_session):
"""Clearing all permissions via PATCH must result in an empty list, not a no-op."""
headers = await _setup_admin(async_client)
create = await async_client.post(
"/api/v1/groups/",
headers=headers,
json={"name": "test_clear", "permissions": ["printers:read", "archives:read"]},
)
gid = create.json()["id"]
update = await async_client.patch(
f"/api/v1/groups/{gid}",
headers=headers,
json={"permissions": []},
)
assert update.status_code == 200
assert update.json()["permissions"] == []
got = await async_client.get(f"/api/v1/groups/{gid}", headers=headers)
assert got.json()["permissions"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_group_without_permissions_field_preserves_existing(async_client: AsyncClient, db_session):
"""PATCH without a permissions field (None) must leave the existing list untouched."""
headers = await _setup_admin(async_client)
create = await async_client.post(
"/api/v1/groups/",
headers=headers,
json={"name": "test_preserve", "permissions": ["printers:read", "archives:read"]},
)
gid = create.json()["id"]
# Only update description
update = await async_client.patch(
f"/api/v1/groups/{gid}",
headers=headers,
json={"description": "updated"},
)
assert update.status_code == 200
assert sorted(update.json()["permissions"]) == ["archives:read", "printers:read"]
assert update.json()["description"] == "updated"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_group_invalid_permission_rejected(async_client: AsyncClient):
"""Invalid permission strings yield 400 and do not persist."""
headers = await _setup_admin(async_client)
create = await async_client.post(
"/api/v1/groups/",
headers=headers,
json={"name": "test_bad", "permissions": ["printers:read"]},
)
gid = create.json()["id"]
update = await async_client.patch(
f"/api/v1/groups/{gid}",
headers=headers,
json={"permissions": ["printers:read", "bogus:permission"]},
)
assert update.status_code == 400
assert "Invalid permissions" in update.json()["detail"]
# Existing value unchanged
got = await async_client.get(f"/api/v1/groups/{gid}", headers=headers)
assert got.json()["permissions"] == ["printers:read"]