mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish
Administrators system group sync - Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have every permission. Upgrades previously only got what one-off backfill blocks in seed_default_groups() explicitly listed (library:purge, archives:purge, the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission enum member added without a matching block silently stayed missing on existing admin rows. The most recent gap was printer_sensor_history:read (Sensor History charts returned 403 for upgraded admins). - seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every startup: append every Permission value that isn't already on the row. Additive only -- hand-added custom permissions are preserved. - The pure-admin one-off backfills (library:purge / archives:purge block, the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators branch of the pipeline backfill) are retired since the sync subsumes them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate cross-group adders) are untouched. - Tests: test_administrators_printer_sensor_history_read_backfilled (regression for the reported gap), test_administrators_sync_covers_every_current_permission (generic invariant -- any future new permission lands on admin without needing a one-off test), test_administrators_sync_is_additive_only (custom permissions preserved). 12/12 backfill-migration + 102/102 broader permission tests green; ruff clean. Pipelines runs dashboard - PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three native <select> elements are replaced with a bambu-themed FilterDropdown (button trigger, floating menu, optgroup-style headers for the Target picker, hover + selected states with a check mark, closes on outside click and Escape). Same value/onChange contract -- visual only. - SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the reference is stable when the data is stable. Fixes the react-hooks/exhaustive-deps warning where the inline fallback returned a fresh empty array every render, invalidating both downstream useMemo caches (target-options + filtered-pipelines list).
This commit is contained in:
@@ -730,6 +730,173 @@ class TestPipelineC:
|
||||
assert body["parent_run_id"] == parent.id
|
||||
|
||||
|
||||
class TestPolishFollowUp:
|
||||
"""Polish-pass fixes: dashboard target filters, clear endpoint, and the
|
||||
deleted-queue-entry → cancelled rollup behaviour."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_dashboard_filters_by_target_printer(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
pipeline_factory,
|
||||
printer_factory,
|
||||
library_file_factory,
|
||||
db_session,
|
||||
):
|
||||
from backend.app.models.pipeline_run import PipelineRun
|
||||
|
||||
printer_a = await printer_factory()
|
||||
printer_b = await printer_factory()
|
||||
pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
|
||||
pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
|
||||
src = await library_file_factory()
|
||||
for pipe in (pipe_a, pipe_a, pipe_b):
|
||||
db_session.add(
|
||||
PipelineRun(
|
||||
pipeline_id=pipe["id"],
|
||||
source_library_file_id=src.id,
|
||||
copies=1,
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_dashboard_filters_by_target_model_class(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
pipeline_factory,
|
||||
printer_factory,
|
||||
library_file_factory,
|
||||
db_session,
|
||||
):
|
||||
from backend.app.models.pipeline_run import PipelineRun
|
||||
|
||||
await printer_factory(model="X1C")
|
||||
await printer_factory(model="P1S")
|
||||
# Two pipelines, one class-targeting X1C, one P1S.
|
||||
pipe_x = await pipeline_factory()
|
||||
await async_client.put(
|
||||
f"/api/v1/slicer-pipelines/{pipe_x['id']}",
|
||||
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
|
||||
)
|
||||
pipe_p = await pipeline_factory()
|
||||
await async_client.put(
|
||||
f"/api/v1/slicer-pipelines/{pipe_p['id']}",
|
||||
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
|
||||
)
|
||||
src = await library_file_factory()
|
||||
for pipe in (pipe_x, pipe_p, pipe_p):
|
||||
db_session.add(
|
||||
PipelineRun(
|
||||
pipeline_id=pipe["id"],
|
||||
source_library_file_id=src.id,
|
||||
copies=1,
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert all(r["target_model_class"] == "P1S" for r in body["runs"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_clear_endpoint_deletes_terminal_runs_only(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
pipeline_factory,
|
||||
printer_factory,
|
||||
library_file_factory,
|
||||
db_session,
|
||||
):
|
||||
from backend.app.models.pipeline_run import PipelineRun
|
||||
|
||||
printer = await printer_factory()
|
||||
pipe = await pipeline_factory(target_printer_id=printer.id)
|
||||
src = await library_file_factory()
|
||||
for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
|
||||
db_session.add(
|
||||
PipelineRun(
|
||||
pipeline_id=pipe["id"],
|
||||
source_library_file_id=src.id,
|
||||
copies=1,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.post("/api/v1/pipeline-runs/clear")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
|
||||
|
||||
# The in-flight rows survive.
|
||||
survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
|
||||
assert survivors["total"] == 2
|
||||
assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deleted_queue_entry_rolls_up_as_cancelled(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
pipeline_factory,
|
||||
printer_factory,
|
||||
library_file_factory,
|
||||
db_session,
|
||||
):
|
||||
"""When the queue entry that a PipelineJob is linked to gets deleted
|
||||
from the print-queue page, the job's live status should roll up to
|
||||
``cancelled`` so the run doesn't sit forever showing ``queued`` /
|
||||
``dispatching``."""
|
||||
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
|
||||
|
||||
printer = await printer_factory()
|
||||
pipe = await pipeline_factory(target_printer_id=printer.id)
|
||||
src = await library_file_factory()
|
||||
# Simulate the state PR C leaves a successful dispatch in: run is
|
||||
# 'dispatching' and the job has a queue_entry_id pointing at a
|
||||
# PrintQueueItem that no longer exists.
|
||||
run = PipelineRun(
|
||||
pipeline_id=pipe["id"],
|
||||
source_library_file_id=src.id,
|
||||
copies=1,
|
||||
status="dispatching",
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
PipelineJob(
|
||||
pipeline_run_id=run.id,
|
||||
copy_index=0,
|
||||
queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
|
||||
assigned_printer_id=printer.id,
|
||||
status="queued",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(run)
|
||||
|
||||
resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# Job rolled up to cancelled because the queue entry is gone.
|
||||
assert body["jobs"][0]["status"] == "cancelled"
|
||||
# Run also rolls up — all jobs cancelled → run reads as cancelled.
|
||||
assert body["status"] == "cancelled"
|
||||
|
||||
|
||||
class TestCancelTerminal:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -218,6 +218,63 @@ class TestReadPermissionMigration:
|
||||
perms = await _get_perms("Operators")
|
||||
assert "orca_cloud:auth" in perms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
|
||||
"""Admin without `printer_sensor_history:read` (older custom edit or
|
||||
a DB seeded before that permission existed) gets it backfilled —
|
||||
regression for the gap maziggy hit on a live install where the
|
||||
per-permission admin backfills missed it."""
|
||||
await seed_default_groups()
|
||||
async with _database_module.async_session() as session:
|
||||
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
|
||||
grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
|
||||
await session.commit()
|
||||
|
||||
await seed_default_groups()
|
||||
|
||||
perms = await _get_perms("Administrators")
|
||||
assert "printer_sensor_history:read" in perms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
|
||||
"""Generic invariant: ALL_PERMISSIONS sync ensures every Permission
|
||||
enum value is present on the Administrators group, no matter what
|
||||
was stripped pre-backfill. Catches every future "new permission
|
||||
missing on upgrade" regression without needing a one-off test."""
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS
|
||||
|
||||
await seed_default_groups()
|
||||
# Wipe the admin group's permission list entirely and force the sync
|
||||
# to put everything back.
|
||||
async with _database_module.async_session() as session:
|
||||
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
|
||||
grp.permissions = []
|
||||
await session.commit()
|
||||
|
||||
await seed_default_groups()
|
||||
|
||||
perms = await _get_perms("Administrators")
|
||||
missing = [p for p in ALL_PERMISSIONS if p not in perms]
|
||||
assert not missing, f"Administrators missing permissions after backfill: {missing}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
|
||||
"""The sync block must never remove a permission an operator added by
|
||||
hand — only add missing entries from ALL_PERMISSIONS."""
|
||||
await seed_default_groups()
|
||||
async with _database_module.async_session() as session:
|
||||
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
|
||||
grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
|
||||
await session.commit()
|
||||
|
||||
await seed_default_groups()
|
||||
|
||||
perms = await _get_perms("Administrators")
|
||||
assert "custom:plugin_permission" in perms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):
|
||||
|
||||
Reference in New Issue
Block a user