mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
134 lines
4.6 KiB
Python
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"]
|