mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +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:
@@ -30,7 +30,7 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy import delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
@@ -284,6 +284,16 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
|
||||
printer_name = p.name if p else None
|
||||
|
||||
live_job_status = _compute_job_status(job.status, queue_entry)
|
||||
# If the job WAS dispatched (had a queue_entry_id) but the entry has
|
||||
# since been deleted from the queue page, the user's intent was
|
||||
# cancellation. Otherwise the run would stay forever showing as
|
||||
# ``queued`` because the persisted job.status hasn't been updated.
|
||||
if (
|
||||
job.queue_entry_id is not None
|
||||
and queue_entry is None
|
||||
and live_job_status not in ("completed", "failed", "cancelled")
|
||||
):
|
||||
live_job_status = "cancelled"
|
||||
job_live_statuses.append(live_job_status)
|
||||
job_responses.append(
|
||||
PipelineJobResponse(
|
||||
@@ -465,6 +475,16 @@ def _make_orchestration_callable(
|
||||
logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
|
||||
return {}
|
||||
|
||||
# Honour a cancel that landed between ``POST /run`` returning and
|
||||
# this background task starting. If the run was cancelled while
|
||||
# still in ``queued`` we must NOT flip it back to ``slicing`` —
|
||||
# the operator's intent was to stop, and overwriting status here
|
||||
# was the bug that left runs stuck at ``dispatching`` after a
|
||||
# user-side cancel (#1425 PR C bug report).
|
||||
if run.status == "cancelled":
|
||||
logger.info("pipeline_run %d was cancelled before slicing started", run_id)
|
||||
return {}
|
||||
|
||||
run.status = "slicing"
|
||||
run.started_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
@@ -512,6 +532,17 @@ def _make_orchestration_callable(
|
||||
|
||||
run.sliced_library_file_id = slice_response.library_file_id
|
||||
|
||||
# Re-check cancellation: the slice can take minutes, and the
|
||||
# operator may have hit Cancel during that window. Refresh from
|
||||
# the DB rather than trusting our in-memory `run` (the cancel
|
||||
# route writes via a separate session). When cancelled, don't
|
||||
# enqueue print queue items — that's the whole point of cancel.
|
||||
await session.refresh(run)
|
||||
if run.status == "cancelled":
|
||||
logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
|
||||
await session.commit()
|
||||
return slice_response.model_dump()
|
||||
|
||||
# PR C: enqueue N copies per the picked assignment strategy.
|
||||
assignments = await _pick_assignments(session, pipeline, copies)
|
||||
|
||||
@@ -542,9 +573,40 @@ def _make_orchestration_callable(
|
||||
|
||||
job.queue_entry_id = queue_item.id
|
||||
job.assigned_printer_id = printer_id # may be None for max_parallel
|
||||
job.status = "queued"
|
||||
# Don't write job.status yet — final cancellation check below
|
||||
# may flip it to 'cancelled' instead. dispatched_at is fine to
|
||||
# set unconditionally since the orchestration actually got here.
|
||||
job.dispatched_at = datetime.now(timezone.utc)
|
||||
|
||||
# Final cancellation check before committing 'dispatching'. The
|
||||
# cancel route writes via a separate session so we have to refresh
|
||||
# to see the latest. If the cancel landed in this narrow window —
|
||||
# AFTER the post-slice refresh but BEFORE this commit — the queue
|
||||
# entries we just created would otherwise pick up and print. Mark
|
||||
# them + the per-copy jobs cancelled so the user's intent sticks.
|
||||
await session.refresh(run)
|
||||
if run.status == "cancelled":
|
||||
logger.info(
|
||||
"pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
|
||||
run_id,
|
||||
len(jobs),
|
||||
)
|
||||
for job in jobs:
|
||||
if job.queue_entry_id:
|
||||
qe = (
|
||||
await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
|
||||
).scalar_one_or_none()
|
||||
if qe is not None and qe.status in ("pending", "queued"):
|
||||
qe.status = "cancelled"
|
||||
if job.status not in ("completed", "failed", "cancelled"):
|
||||
job.status = "cancelled"
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
return slice_response.model_dump()
|
||||
|
||||
for job in jobs:
|
||||
job.status = "queued"
|
||||
run.status = "dispatching"
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
@@ -720,13 +782,17 @@ async def list_all_runs(
|
||||
offset: int = 0,
|
||||
pipeline_id: int | None = None,
|
||||
status: str | None = None,
|
||||
target_printer_id: int | None = None,
|
||||
target_model_class: str | None = None,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Dashboard list. Newest first; filters on pipeline_id + status. The
|
||||
`status` filter matches the persisted snapshot, not the live roll-up —
|
||||
in-progress runs may appear under `dispatching` until the next state
|
||||
transition writes through."""
|
||||
"""Dashboard list. Newest first; filters on pipeline_id + status +
|
||||
target_printer_id + target_model_class. The ``status`` filter matches
|
||||
the persisted snapshot, not the live roll-up — in-progress runs may
|
||||
appear under ``dispatching`` until the next state transition writes
|
||||
through. ``target_*`` filters JOIN to the pipeline so runs whose
|
||||
pipeline currently points at the printer / class are returned."""
|
||||
limit = max(1, min(limit, 100))
|
||||
offset = max(0, offset)
|
||||
|
||||
@@ -738,6 +804,15 @@ async def list_all_runs(
|
||||
if status:
|
||||
stmt = stmt.where(PipelineRun.status == status)
|
||||
count_stmt = count_stmt.where(PipelineRun.status == status)
|
||||
if target_printer_id is not None or target_model_class is not None:
|
||||
stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
|
||||
count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
|
||||
if target_printer_id is not None:
|
||||
stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
|
||||
count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
|
||||
if target_model_class is not None:
|
||||
stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
|
||||
count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
|
||||
|
||||
rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
@@ -748,6 +823,31 @@ async def list_all_runs(
|
||||
)
|
||||
|
||||
|
||||
_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
|
||||
|
||||
|
||||
@pipeline_run_router.post("/clear")
|
||||
async def clear_terminal_runs(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete every terminal pipeline run (completed / failed / cancelled /
|
||||
partial_failure). In-flight runs (queued / slicing / dispatching /
|
||||
in_progress) are preserved — clearing those mid-flight would lose the
|
||||
operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
|
||||
relationship; the linked PrintQueueItem rows stay (they have their own
|
||||
lifecycle on the queue page)."""
|
||||
# Count first so the response can report how many got cleared. Done
|
||||
# under the same session/transaction as the delete so the numbers can't
|
||||
# drift if another caller races in.
|
||||
count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
|
||||
n = (await db.execute(count_stmt)).scalar() or 0
|
||||
if n > 0:
|
||||
await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
|
||||
await db.commit()
|
||||
return {"deleted": n}
|
||||
|
||||
|
||||
@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
|
||||
async def get_run(
|
||||
run_id: int,
|
||||
|
||||
@@ -3345,7 +3345,7 @@ async def seed_default_groups():
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.permissions import DEFAULT_GROUPS
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.user import User
|
||||
|
||||
@@ -3498,63 +3498,31 @@ async def seed_default_groups():
|
||||
group.permissions = perms
|
||||
await session.commit()
|
||||
|
||||
# Backfill library:purge + archives:purge for the Administrators group
|
||||
# on existing installs. Both permissions were added after Administrators
|
||||
# was first seeded, so upgrading users miss them even though the default
|
||||
# config (ALL_PERMISSIONS) includes them for fresh installs.
|
||||
# Backfill: sync the Administrators system group to ALL_PERMISSIONS.
|
||||
# Administrators' contract is full access to every feature — fresh
|
||||
# installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
|
||||
# = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
|
||||
# whatever permission set existed when they were first seeded, so a
|
||||
# newly-added Permission enum member silently leaves admins gated out
|
||||
# of the feature it controls.
|
||||
#
|
||||
# Generalises the previous one-off admin backfills (library:purge,
|
||||
# archives:purge, the OWN/ALL read-flag set + legacy read flags,
|
||||
# orca_cloud:auth, printer_sensor_history:read, …): every current
|
||||
# Permission enum value is appended to the admin group if missing.
|
||||
# Additive only — never removes a permission an operator added by
|
||||
# hand. Run AFTER the legacy-rename migration above so the renamed
|
||||
# OWN/ALL variants land in the group before the sync sees them.
|
||||
result = await session.execute(select(Group).where(Group.name == "Administrators"))
|
||||
admin_group = result.scalar_one_or_none()
|
||||
if admin_group and admin_group.permissions is not None:
|
||||
perms = list(admin_group.permissions)
|
||||
added = False
|
||||
for new_perm in ("library:purge", "archives:purge"):
|
||||
for new_perm in ALL_PERMISSIONS:
|
||||
if new_perm not in perms:
|
||||
perms.append(new_perm)
|
||||
added = True
|
||||
logger.info("Added %s to Administrators group (backfill)", new_perm)
|
||||
if added:
|
||||
admin_group.permissions = perms
|
||||
await session.commit()
|
||||
|
||||
# Backfill the read flag set for the Administrators group on existing
|
||||
# installs (maziggy/bambuddy-security #2). Two layers:
|
||||
#
|
||||
# (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
|
||||
# these via ALL_PERMISSIONS; upgrades need the explicit backfill
|
||||
# so admin's permission set matches a fresh install's.
|
||||
#
|
||||
# (b) Legacy `archives:read` / `library:read` / `queue:read`. The
|
||||
# frontend still gates download / preview UI on these LEGACY
|
||||
# strings (see ArchivesPage / FileManagerPage), so admin needs
|
||||
# them retained even though the new API uses the OWN/ALL split.
|
||||
# The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
|
||||
# read flags for admin — this backfill ensures they're present
|
||||
# even if they were stripped by hand or by an older migration.
|
||||
#
|
||||
# Also includes orca_cloud:auth for parity with fresh-install
|
||||
# behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
|
||||
# admin role that's been customised since seed still has it).
|
||||
result = await session.execute(select(Group).where(Group.name == "Administrators"))
|
||||
admin_group = result.scalar_one_or_none()
|
||||
if admin_group and admin_group.permissions is not None:
|
||||
perms = list(admin_group.permissions)
|
||||
added = False
|
||||
for new_perm in (
|
||||
"archives:read",
|
||||
"archives:read_own",
|
||||
"archives:read_all",
|
||||
"library:read",
|
||||
"library:read_own",
|
||||
"library:read_all",
|
||||
"queue:read",
|
||||
"queue:read_own",
|
||||
"queue:read_all",
|
||||
"orca_cloud:auth",
|
||||
):
|
||||
if new_perm not in perms:
|
||||
perms.append(new_perm)
|
||||
added = True
|
||||
logger.info("Added %s to Administrators group (backfill)", new_perm)
|
||||
logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
|
||||
if added:
|
||||
admin_group.permissions = perms
|
||||
await session.commit()
|
||||
@@ -3613,25 +3581,18 @@ async def seed_default_groups():
|
||||
group.permissions = perms
|
||||
await session.commit()
|
||||
|
||||
# Backfill pipeline permissions (#1425). Pipelines were added after
|
||||
# initial seeding, so existing groups need them appended:
|
||||
# - Administrators: all three (matches fresh-install ALL_PERMISSIONS)
|
||||
# Backfill pipeline permissions (#1425) for non-admin groups.
|
||||
# Administrators is handled by the ALL_PERMISSIONS sync above.
|
||||
# - Operators: all three (matches fresh-install DEFAULT_GROUPS)
|
||||
# - Viewers + any group with library:read_own or settings:read:
|
||||
# - Any other group with library:read_own or settings:read:
|
||||
# pipelines:read only
|
||||
result = await session.execute(select(Group))
|
||||
for group in result.scalars().all():
|
||||
if not group.permissions:
|
||||
if not group.permissions or group.name == "Administrators":
|
||||
continue
|
||||
perms = list(group.permissions)
|
||||
changed = False
|
||||
if group.name == "Administrators":
|
||||
for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
|
||||
if new_perm not in perms:
|
||||
perms.append(new_perm)
|
||||
changed = True
|
||||
logger.info("Added %s to Administrators group (backfill)", new_perm)
|
||||
elif group.name == "Operators":
|
||||
if group.name == "Operators":
|
||||
for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
|
||||
if new_perm not in perms:
|
||||
perms.append(new_perm)
|
||||
|
||||
@@ -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